From 252070cd3266cef46dd9e6dce9a16f80d9e1a0a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 9 Sep 2026 11:01:40 +0200 Subject: [PATCH 1/6] perf(ios): recover deep snapshots and isolate optional tap probes --- .../RunnerTests+CommandExecution.swift | 2 +- .../RunnerTests+Interaction.swift | 31 ---- .../RunnerTests+TextInputProbe.swift | 102 +++++++++++ .../RunnerTests.swift | 4 + .../RunnerTests+TextInputProbeTests.swift | 168 ++++++++++++++++++ apple/snapshot-bridge/README.md | 12 +- apple/snapshot-bridge/SnapshotBridgeCapture.h | 11 ++ apple/snapshot-bridge/SnapshotBridgeCapture.m | 113 ++++++++++++ apple/snapshot-bridge/SnapshotBridgeRuntime.m | 20 ++- .../0011-interaction-guarantee-contract.md | 21 +++ .../src/snapshot-source/adapter.test.ts | 11 +- .../src/snapshot-source/adapter.ts | 2 +- .../src/snapshot-source/cache-identity.ts | 3 + .../src/snapshot-source/cache.test.ts | 6 + .../fixtures/foreground-owner.m | 85 ++++++++- .../fixtures/wire-vocabulary.json | 2 +- .../snapshot-source/native-runtime.test.ts | 31 +++- .../src/snapshot-source/protocol.test.ts | 10 +- .../src/snapshot-source/protocol.ts | 2 +- 19 files changed, 582 insertions(+), 54 deletions(-) create mode 100644 apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextInputProbe.swift create mode 100644 apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextInputProbeTests.swift create mode 100644 apple/snapshot-bridge/SnapshotBridgeCapture.h create mode 100644 apple/snapshot-bridge/SnapshotBridgeCapture.m diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index bd647eeba4..c5ba172e1a 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -1795,7 +1795,7 @@ extension RunnerTests { ) let textInput: XCUIElement? if !xCTestTextInputProbeSkipped { - textInput = textInputAt(app: activeApp, x: x, y: y) + textInput = coordinateTapTextInputAt(app: activeApp, x: x, y: y) } else { // A process-scoped tap cannot authorize later typing without concrete element identity. textInput = nil diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift index a706d2d2d5..2cb8b465b4 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift @@ -318,37 +318,6 @@ extension RunnerTests { return nil } - func textInputAt(app: XCUIApplication, x: Double, y: Double) -> XCUIElement? { - return textInputCandidatesAt(app: app, point: CGPoint(x: x, y: y)).first - } - - private func textInputCandidatesAt(app: XCUIApplication, point: CGPoint) -> [XCUIElement] { - safely("TEXT_INPUT_AT_POINT", []) { - // Query the text-input element types directly instead of enumerating the entire tree - // (app.descendants(.any).allElementsBoundByIndex snapshots every element and is ~10x - // slower — it dominated fill latency because resolveTextEntryElement re-runs this on - // each verify/repair poll once the focused field reference goes stale). - // Prefer the smallest matching field so nested editable controls win over large containers. - [ - app.textFields, - app.secureTextFields, - app.searchFields, - app.textViews, - ] - .flatMap { $0.allElementsBoundByIndex } - .filter { element in - guard element.exists else { return false } - let frame = element.frame - return isCoordinateTextInputCandidate( - enabled: element.isEnabled, - frame: frame, - point: point - ) - } - .sorted(by: smallestElementFirst) - } - } - private func readableText(for element: XCUIElement) -> String? { let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines) let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextInputProbe.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextInputProbe.swift new file mode 100644 index 0000000000..5fb209a6aa --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextInputProbe.swift @@ -0,0 +1,102 @@ +import XCTest + +final class TextInputProbeIssues { + let thread = Thread.current + var count = 0 +} + +enum TextInputProbeFailure: String { + case recordedIssue = "text_input_probe_recorded_issue" + case exception = "text_input_probe_exception" +} + +enum TextInputProbeOutcome { + case matches([XCUIElement]) + case absent + case unavailable(TextInputProbeFailure) +} + +extension RunnerTests { + func textInputAt(app: XCUIApplication, x: Double, y: Double) -> XCUIElement? { + textInputCandidatesAt(app: app, point: CGPoint(x: x, y: y)).first + } + + func textInputCandidatesAt(app: XCUIApplication, point: CGPoint) -> [XCUIElement] { + safely("TEXT_INPUT_AT_POINT", []) { + queryTextInputs(app: app, point: point, shouldStop: { false }) + } + } + + func coordinateTapTextInputAt(app: XCUIApplication, x: Double, y: Double) -> XCUIElement? { + switch probeTextInputs(app: app, point: CGPoint(x: x, y: y)) { + case .matches(let elements): + return elements.first + case .absent: + return nil + case .unavailable(let failure): + penalizeSnapshotXCTestChannel(bundleId: currentBundleId, reason: failure.rawValue) + return nil + } + } + + func probeTextInputs(app: XCUIApplication, point: CGPoint) -> TextInputProbeOutcome { + precondition(Thread.isMainThread) + let issues = TextInputProbeIssues() + suppressedIssueLock.lock() + let previous = textInputProbeIssues + textInputProbeIssues = issues + suppressedIssueLock.unlock() + defer { + suppressedIssueLock.lock() + textInputProbeIssues = previous + suppressedIssueLock.unlock() + } + let (elements, exception) = catchingObjCException(fallback: []) { + queryTextInputs(app: app, point: point, shouldStop: { self.hasTextInputProbeIssues(issues) }) + } + if hasTextInputProbeIssues(issues) { return .unavailable(.recordedIssue) } + if exception != nil { return .unavailable(.exception) } + return elements.isEmpty ? .absent : .matches(elements) + } + + private func hasTextInputProbeIssues(_ scope: TextInputProbeIssues) -> Bool { + suppressedIssueLock.lock() + defer { suppressedIssueLock.unlock() } + return scope.count > 0 + } + + func containTextInputProbeIssue(_ issue: XCTIssue) -> Bool { + suppressedIssueLock.lock() + guard let scope = textInputProbeIssues, scope.thread === Thread.current else { + suppressedIssueLock.unlock() + return false + } + scope.count += 1 + suppressedIssueLock.unlock() + NSLog("AGENT_DEVICE_RUNNER_TEXT_INPUT_PROBE_UNAVAILABLE issue=%@", issue.compactDescription) + return true + } + + private func queryTextInputs( + app: XCUIApplication, + point: CGPoint, + shouldStop: () -> Bool + ) -> [XCUIElement] { + var candidates: [XCUIElement] = [] + for query in [app.textFields, app.secureTextFields, app.searchFields, app.textViews] { + if shouldStop() { break } + candidates.append(contentsOf: query.allElementsBoundByIndex) +#if AGENT_DEVICE_RUNNER_UNIT_TESTS + if let issue = textInputProbeIssueForTesting { + textInputProbeIssueForTesting = nil + record(issue) + } +#endif + } + guard !shouldStop() else { return [] } + return candidates.filter { element in + guard !shouldStop(), element.exists else { return false } + return isCoordinateTextInputCandidate(enabled: element.isEnabled, frame: element.frame, point: point) + }.sorted(by: smallestElementFirst) + } +} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index 5c7f418499..abb6737b1b 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -143,6 +143,8 @@ final class RunnerTests: XCTestCase { // The injection records a real XCTIssue AFTER the real gesture, so // `xctestRecordedFailureResponse` and target invalidation fire byte-for-byte // like a field failure. Production builds compile none of this. + var textInputProbeIssueForTesting: XCTIssue? + static let injectedTapFailureFlagPathForTesting = "/tmp/agent-device-inject-tap-recorded-failure-for-testing" @@ -182,6 +184,7 @@ final class RunnerTests: XCTestCase { #endif // Observability for the record(_:) suppression below: how many AX-broken-screen snapshot // issues this session muted, so wedge investigations see the volume without grepping logs. + var textInputProbeIssues: TextInputProbeIssues? let suppressedIssueLock = NSLock() var suppressedAxSnapshotIssueCount = 0 // Keep blocker actions narrow to avoid false positives from generic hittable containers. @@ -221,6 +224,7 @@ final class RunnerTests: XCTestCase { /// outcomes stay honest through their own error paths — only this issue side-channel is /// muted. Everything else still records (and still drives XCTEST_RECORDED_FAILURE). override func record(_ issue: XCTIssue) { + if containTextInputProbeIssue(issue) { return } let description = issue.compactDescription if Self.isSuppressedAxSnapshotIssueDescription(description) { suppressedIssueLock.lock() diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextInputProbeTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextInputProbeTests.swift new file mode 100644 index 0000000000..8e402dd12b --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextInputProbeTests.swift @@ -0,0 +1,168 @@ +import XCTest + +extension RunnerTests { +#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) + func testTextInputProbePreservesEnclosingRunnerWait() { + app.launchArguments = ["--agent-device-text-entry-regression"] + app.launch() + defer { + textInputProbeIssueForTesting = nil + app.terminate() + } + let field = app.textFields["agent-device-hardware-keyboard-input"] + XCTAssertTrue(field.waitForExistence(timeout: appExistenceTimeout)) + let point = CGPoint(x: field.frame.midX, y: field.frame.midY) + let completed = expectation(description: "optional probe completed inside runner wait") + DispatchQueue.main.async { + self.textInputProbeIssueForTesting = XCTIssue(type: .assertionFailure, compactDescription: "Optional probe issue during runner wait") + _ = self.probeTextInputs(app: self.app, point: point) + completed.fulfill() + } + guard XCTWaiter.wait(for: [completed], timeout: 5) == .completed else { + return XCTFail("Optional probe interrupted the runner wait") + } + XCTAssertNil(textInputProbeIssues) + NSLog("AGENT_DEVICE_RUNNER_OPTIONAL_PROBE_WAIT_COMPLETED") + } + + func testTextInputProbeIssueScopeIsThreadBound() { + let issue = XCTIssue(type: .assertionFailure, compactDescription: "Issue scope thread check") + XCTAssertFalse(containTextInputProbeIssue(issue)) + let scope = TextInputProbeIssues() + suppressedIssueLock.lock() + textInputProbeIssues = scope + suppressedIssueLock.unlock() + defer { + suppressedIssueLock.lock() + textInputProbeIssues = nil + suppressedIssueLock.unlock() + } + let finished = DispatchSemaphore(value: 0) + let result = ProbeThreadResult() + Thread.detachNewThread { + result.contained = self.containTextInputProbeIssue(issue) + finished.signal() + } + guard finished.wait(timeout: .now() + 2) == .success else { + return XCTFail("Background issue classification did not finish") + } + XCTAssertFalse(result.contained) + XCTAssertEqual(scope.count, 0) + XCTAssertTrue(containTextInputProbeIssue(issue)) + XCTAssertEqual(scope.count, 1) + } + + func testHealthyCoordinateTapPreservesBareTypingWitness() throws { + app.launchArguments = ["--agent-device-text-entry-regression"] + app.launch() + defer { + invalidateCachedTarget(reason: "unit_test_cleanup") + app.terminate() + } + let field = app.textFields["agent-device-hardware-keyboard-input"] + XCTAssertTrue(field.waitForExistence(timeout: appExistenceTimeout)) + let frame = field.frame + currentApp = app + currentBundleId = "com.callstack.agentdevice.runner" + currentAppProcessIdentifier = try XCTUnwrap(Self.processIdentifier(of: app)) + clearSnapshotXCTestChannelPenalty(reason: "fresh-runner") + let failures = currentXCTestFailureCount() + let tap = try runnerCommandFixture( + #"{"appBundleId":"com.callstack.agentdevice.runner","command":"tap","commandId":"tap-healthy-probe","x":\#(frame.midX),"y":\#(frame.midY),"synthesized":true}"# + ) + let tapped = try execute(command: tap) + XCTAssertTrue(tapped.ok, String(describing: tapped.error)) + XCTAssertNotNil(textEntryTapWitness) + XCTAssertFalse(isSnapshotXCTestChannelPenalized(bundleId: currentBundleId)) + try XCTSkipIf(isKeyboardVisible(app: app), "software keyboard is up; hidden-keyboard witness cannot be exercised") + let type = try runnerCommandFixture(#"{"appBundleId":"com.callstack.agentdevice.runner","command":"type","commandId":"type-healthy-probe","text":"probe-witness"}"#) + let typed = try execute(command: type) + XCTAssertTrue(typed.ok, String(describing: typed.error)) + XCTAssertEqual(typed.data?.textEntryRoute, "synthesized-first-responder") + XCTAssertEqual(field.value as? String, "probe-witness") + XCTAssertFalse(didRecordXCTestFailure(since: failures)) + } + + func testTextInputProbeContainmentExcludesRequiredReadsAndLaterIssues() { + app.launchArguments = ["--agent-device-text-entry-regression"] + app.launch() + defer { + textInputProbeIssueForTesting = nil + app.terminate() + } + let field = app.textFields["agent-device-hardware-keyboard-input"] + XCTAssertTrue(field.waitForExistence(timeout: appExistenceTimeout)) + let point = CGPoint(x: field.frame.midX, y: field.frame.midY) + let expected = XCTIssue(type: .assertionFailure, compactDescription: "Required query failure must escape optional containment") + let options = XCTExpectedFailure.Options() + var observed = 0 + options.issueMatcher = { issue in + guard issue.type == expected.type, issue.compactDescription == expected.compactDescription else { return false } + observed += 1 + return true + } + XCTExpectFailure("Required read and later issue belong to their caller", options: options) { + textInputProbeIssueForTesting = expected + _ = textInputAt(app: app, x: point.x, y: point.y) + _ = probeTextInputs(app: app, point: point) + record(expected) + } + XCTAssertEqual(observed, 2) + } + + func testSuppressedAxIssueMakesTextInputProbeUnavailable() throws { + app.launchArguments = ["--agent-device-text-entry-regression"] + app.launch() + defer { + textInputProbeIssueForTesting = nil + app.terminate() + } + let field = app.textFields["agent-device-hardware-keyboard-input"] + XCTAssertTrue(field.waitForExistence(timeout: appExistenceTimeout)) + let frame = field.frame + textInputProbeIssueForTesting = XCTIssue(type: .assertionFailure, compactDescription: "Failed to get matching snapshot: kAXErrorIllegalArgument") + let outcome = probeTextInputs(app: app, point: CGPoint(x: frame.midX, y: frame.midY)) + guard case .unavailable = outcome else { + return XCTFail("A suppressed AX issue must discard the matching candidate") + } + } + + func testFreshCoordinateTapContainsUnavailableTextInputProbe() throws { + app.launchArguments = ["--agent-device-text-entry-regression"] + app.launch() + defer { + textInputProbeIssueForTesting = nil + clearSnapshotXCTestChannelPenalty(reason: "test-cleanup") + invalidateCachedTarget(reason: "unit_test_cleanup") + app.terminate() + } + let target = app.staticTexts["Agent Device Runner"] + XCTAssertTrue(target.waitForExistence(timeout: appExistenceTimeout)) + let frame = target.frame + currentApp = app + currentBundleId = "com.callstack.agentdevice.runner" + currentAppProcessIdentifier = try XCTUnwrap(Self.processIdentifier(of: app)) + clearSnapshotXCTestChannelPenalty(reason: "fresh-runner") + let failures = currentXCTestFailureCount() + textInputProbeIssueForTesting = XCTIssue(type: .assertionFailure, compactDescription: "Injected optional text input query failure") + let command = try runnerCommandFixture( + #"{"appBundleId":"com.callstack.agentdevice.runner","command":"tap","commandId":"tap-probe-unavailable","x":\#(frame.midX),"y":\#(frame.midY),"synthesized":true}"# + ) + let response = try execute(command: command) + XCTAssertTrue(response.ok, String(describing: response.error)) + XCTAssertFalse(didRecordXCTestFailure(since: failures)) + XCTAssertTrue(isSnapshotXCTestChannelPenalized(bundleId: currentBundleId)) + XCTAssertNil(textEntryTapWitness) + let type = try runnerCommandFixture(#"{"appBundleId":"com.callstack.agentdevice.runner","command":"type","commandId":"type-after-unavailable-probe","text":"must-not-type"}"#) + let typed = try execute(command: type) + XCTAssertFalse(typed.ok) + XCTAssertEqual(typed.error?.code, "TEXT_INPUT_NOT_FOCUSED") + } +#endif +} + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) +private final class ProbeThreadResult: @unchecked Sendable { + var contained = false +} +#endif diff --git a/apple/snapshot-bridge/README.md b/apple/snapshot-bridge/README.md index d3769abb46..1c182f5085 100644 --- a/apple/snapshot-bridge/README.md +++ b/apple/snapshot-bridge/README.md @@ -8,7 +8,7 @@ and is never downloaded, pre-signed, or built by npm installation. The guest process uses the `XCTAccessibilityFramework` remote-access client from the simulator runtime and the `userTestingSnapshotForElement:options:error:` -single-fetch API. Requests and responses are length-prefixed JSON frames: +snapshot API. Requests and responses are length-prefixed JSON frames: ```text uint32 big-endian byte length @@ -47,3 +47,13 @@ app tree. The existing route then uses XCTest, which owns system-modal resolution. Secondary owners such as the return-to-app status-bar control do not replace the native primary owner. The route's generation circuit remains disabled after fallback until that app relaunches. + +## Bounded depth recovery + +A healthy capture uses one native request. If native acquisition rejects it, +`SnapshotBridgeCapture.m` retries supported native failure codes at lower depths +and fetches withheld children from their accessibility elements. The completed +tree keeps the original depth and node limits; partial trees disclose truncation. +Recovery allows at most 32 native requests within the existing capture deadline, +checks foreground ownership on every request, and returns a failure when it +cannot complete a continuation. The route then retains its XCTest fallback. diff --git a/apple/snapshot-bridge/SnapshotBridgeCapture.h b/apple/snapshot-bridge/SnapshotBridgeCapture.h new file mode 100644 index 0000000000..0afaa22f31 --- /dev/null +++ b/apple/snapshot-bridge/SnapshotBridgeCapture.h @@ -0,0 +1,11 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef id _Nullable (^SnapshotElementReader)(id element, NSUInteger depth, NSUInteger nodes, NSError **error); + +/// Materializes one bounded tree; retries bounded native acquisition failures and re-roots withheld children. +NSDictionary *_Nullable captureSnapshotTree(id element, NSUInteger maxDepth, NSUInteger maxNodes, + SnapshotElementReader reader, BOOL *truncated, NSError **error); + +NS_ASSUME_NONNULL_END diff --git a/apple/snapshot-bridge/SnapshotBridgeCapture.m b/apple/snapshot-bridge/SnapshotBridgeCapture.m new file mode 100644 index 0000000000..321650f421 --- /dev/null +++ b/apple/snapshot-bridge/SnapshotBridgeCapture.m @@ -0,0 +1,113 @@ +#import "SnapshotBridgeCapture.h" + +static NSString *const attributesKey = @"UIAccessibilitySnapshotKeyAttributes"; +static NSString *const childrenKey = @"UIAccessibilitySnapshotKeyChildren"; +static NSString *const childCountKey = @"UIAccessibilitySnapshotKeyChildrenCount"; +static NSString *const elementKey = @"UIAccessibilitySnapshotKeyElement"; +static const NSUInteger maximumRequests = 32; + +@interface SnapshotTreeCapture : NSObject +@property(nonatomic, copy) SnapshotElementReader reader; +@property(nonatomic) NSUInteger acceptedDepth; +@property(nonatomic) NSUInteger remainingNodes; +@property(nonatomic) NSUInteger maximumNodes; +@property(nonatomic) NSUInteger requests; +@property(nonatomic) BOOL truncated; +- (nullable NSDictionary *)read:(id)element depth:(NSUInteger)depth error:(NSError **)error; +- (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)depth error:(NSError **)error; +@end + +@implementation SnapshotTreeCapture +- (nullable NSDictionary *)read:(id)element depth:(NSUInteger)depth error:(NSError **)error +{ + NSUInteger attemptDepth = MIN(depth, self.acceptedDepth); + for (;;) { + if (self.requests >= maximumRequests) { + if (error) *error = [NSError errorWithDomain:@"agent-device.snapshot" code:1 + userInfo:@{NSLocalizedDescriptionKey: @"snapshot continuation request budget exhausted"}]; + return nil; + } + self.requests++; + NSError *failure = nil; + id tree = self.reader(element, attemptDepth, MIN(self.maximumNodes, self.remainingNodes + 1), &failure); + if (tree) return tree; + NSNumber *nativeCode = failure.userInfo[@"accessibility-error"]; + BOOL rejected = ([nativeCode isKindOfClass:NSNumber.class] && nativeCode.integerValue == -25201) || + ([failure.domain isEqualToString:@"com.apple.dt.xctest.automation-support.error"] && failure.code == 5); + if (!rejected || attemptDepth <= 1) { + if (error) *error = failure; + return nil; + } + attemptDepth = MAX(1, attemptDepth / 2); + self.acceptedDepth = attemptDepth; + } +} + +- (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)depth error:(NSError **)error +{ + 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 + userInfo:@{NSLocalizedDescriptionKey: @"malformed snapshot continuation"}]; + return nil; + } + if (self.remainingNodes == 0) { + self.truncated = YES; + return nil; + } + self.remainingNodes--; + NSArray *children = tree[childrenKey]; + NSNumber *childCount = tree[childCountKey]; + BOOL withheld = [childCount isKindOfClass:NSNumber.class] && childCount.unsignedIntegerValue > children.count; + NSMutableDictionary *result = [tree mutableCopy]; + if (depth <= 1 || self.remainingNodes == 0) { + self.truncated |= children.count > 0 || withheld; + result[childrenKey] = @[]; + return result; + } + if (withheld && children.count < self.remainingNodes) { + id element = tree[elementKey]; + if (!element) { + if (error) *error = [NSError errorWithDomain:@"agent-device.snapshot" code:3 + userInfo:@{NSLocalizedDescriptionKey: @"snapshot continuation element unavailable"}]; + return nil; + } + NSDictionary *continuation = [self read:element depth:depth error:error]; + if (!continuation) return nil; + 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 + userInfo:@{NSLocalizedDescriptionKey: @"snapshot continuation children unavailable"}]; + return nil; + } + } + if (withheld && children.count < childCount.unsignedIntegerValue) self.truncated = YES; + NSMutableArray *materialized = [NSMutableArray array]; + for (NSDictionary *child in children) { + if (self.remainingNodes == 0) { + self.truncated = YES; + break; + } + NSDictionary *node = [self materialize:child depth:depth - 1 error:error]; + if (!node) return nil; + [materialized addObject:node]; + } + result[childrenKey] = materialized; + return result; +} +@end + +NSDictionary *captureSnapshotTree(id element, NSUInteger maxDepth, NSUInteger maxNodes, + SnapshotElementReader reader, BOOL *truncated, NSError **error) +{ + SnapshotTreeCapture *capture = [SnapshotTreeCapture new]; + capture.reader = reader; + capture.acceptedDepth = maxDepth; + capture.remainingNodes = maxNodes; + capture.maximumNodes = maxNodes; + NSDictionary *tree = [capture read:element depth:maxDepth error:error]; + NSDictionary *result = tree ? [capture materialize:tree depth:MAX(1, maxDepth) error:error] : nil; + *truncated = capture.truncated; + return result; +} diff --git a/apple/snapshot-bridge/SnapshotBridgeRuntime.m b/apple/snapshot-bridge/SnapshotBridgeRuntime.m index 9579dfa554..b92ba60950 100644 --- a/apple/snapshot-bridge/SnapshotBridgeRuntime.m +++ b/apple/snapshot-bridge/SnapshotBridgeRuntime.m @@ -5,6 +5,7 @@ */ #import "SnapshotBridgeRuntime.h" +#import "SnapshotBridgeCapture.h" #import #import @@ -17,7 +18,7 @@ NSString *const kProtocolVersionKey = @"protocolVersion"; NSString *const kSourceVersionKey = @"sourceVersion"; NSString *const kRequestIdKey = @"requestId"; -NSString *const kSourceVersion = @"agent-device-simulator-ax-v1.5.3"; +NSString *const kSourceVersion = @"agent-device-simulator-ax-v1.5.4"; const NSUInteger kProtocolVersion = 1; const uint32_t kMaximumFrameBytes = 16 * 1024 * 1024; const NSUInteger kMaximumDepth = 128; @@ -311,13 +312,26 @@ - (nullable NSDictionary *)snapshotForProcess:(pid_t)pid BOOL automationEnabled = [self assertAutomationMode:YES]; NSError *runtimeError = nil; id snapshot = nil; + BOOL acquisitionTruncated = NO; @try { if (![self isPrimaryForegroundProcess:pid]) { if (error) *error = failureResponse(requestId, @"unsupported", @"foreground-owner-unverified", @"target app is not the primary foreground accessibility owner"); finishRequestWatchdog(watchdog, watchdogState); return nil; } - snapshot = [_framework userTestingSnapshotForElement:(__bridge id)raw options:options error:&runtimeError]; + snapshot = captureSnapshotTree((__bridge id)raw, maxDepth, maxNodes, + ^id(id element, NSUInteger depth, NSUInteger nodes, NSError **captureError) { + if (![self isPrimaryForegroundProcess:pid]) { + if (captureError) *captureError = [NSError errorWithDomain:@"agent-device.snapshot" code:5 + userInfo:@{NSLocalizedDescriptionKey: @"foreground owner changed during continuation"}]; + return nil; + } + NSMutableDictionary *bounded = [options mutableCopy]; + bounded[@"maxDepth"] = @(depth); + bounded[@"maxChildren"] = @(nodes); + bounded[@"maxArrayCount"] = @(nodes); + return [_framework userTestingSnapshotForElement:element options:bounded error:captureError]; + }, &acquisitionTruncated, &runtimeError); if (![self isPrimaryForegroundProcess:pid]) { if (error) *error = failureResponse(requestId, @"unsupported", @"foreground-owner-changed", @"foreground accessibility ownership changed during acquisition"); finishRequestWatchdog(watchdog, watchdogState); @@ -362,7 +376,7 @@ - (nullable NSDictionary *)snapshotForProcess:(pid_t)pid @"ok" : @YES, @"pid" : @(pid), @"tree" : tree, - @"truncated" : @(truncated), + @"truncated" : @((BOOL)(truncated || acquisitionTruncated)), @"automationEnabled" : @(automationEnabled), }; } diff --git a/docs/adr/0011-interaction-guarantee-contract.md b/docs/adr/0011-interaction-guarantee-contract.md index d9631d26d9..dff6368dcf 100644 --- a/docs/adr/0011-interaction-guarantee-contract.md +++ b/docs/adr/0011-interaction-guarantee-contract.md @@ -326,3 +326,24 @@ Each step lands green and independently useful: - **More integration tests without the registry**: this is the status quo plus effort. Without the matrix as code, nothing forces a new path to acquire the existing suite, which is exactly how this week's bugs happened. + +### Optional observation before an iOS coordinate tap + +A coordinate tap must not depend on a preceding XCTest snapshot failure. Its +optional text-input lookup may establish a concrete identity for a later bare +`type`; an absent or unavailable lookup establishes no typing witness. A runner +snapshot penalty can skip this work, but is only a performance optimization. + +The lookup owns a thread-bound issue scope in the runner recorder and returns a +typed result. Any recorded issue, including one otherwise handled by AX suppression, +discards partial candidates. The scope excludes gesture dispatch and required +text-entry reads. Those failures retain the existing mutation-outcome rules. + +The iOS PR lane exercises a fresh runner with an unavailable probe, a suppressed +AX issue with a matching candidate, healthy coordinate tap followed by typing, +and failures outside the optional observation scope. These tests must not seed a +snapshot penalty to make the first tap safe. + +The recorder consumes optional-read issues before forwarding to XCTest. XCTest's +expected-failure API must not own this scope: in a long-lived command test it can +complete the enclosing test even when the command response succeeds. diff --git a/packages/platform-apple/src/snapshot-source/adapter.test.ts b/packages/platform-apple/src/snapshot-source/adapter.test.ts index e3b1278af8..ec63e6416d 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.test.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.test.ts @@ -25,6 +25,8 @@ test('the Simulator AX source returns raw acquisition facts and discloses unsupp await writeFile(path.join(sourceRoot, 'SnapshotBridge.m'), 'native source'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header'); + await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header'); + await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header'); const fixture = createAdapterHost(); const source = createSimulatorSnapshotSource({ host: fixture.host, @@ -74,6 +76,11 @@ test('the Simulator AX source returns raw acquisition facts and discloses unsupp }); assert.equal(rawDepthOne.stage, 'acquired'); assert.equal(fixture.requestedDepths.at(-1), 1); + assert.ok( + rawDepthOne.acquisition.residue.some( + (item) => item.kind === 'truncated' && item.dimension === 'depth', + ), + ); fixture.responsePid = 999; const outcome = await source.acquire({ @@ -97,6 +104,8 @@ test('preparation consumes the same acquisition deadline as bridge I/O', async ( await writeFile(path.join(sourceRoot, 'SnapshotBridge.m'), 'native source'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header'); + await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header'); + await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header'); const fixture = createAdapterHost(150); const source = createSimulatorSnapshotSource({ host: fixture.host, sourceRoot, cacheRoot }); const request = createIosSnapshotRequest(); @@ -235,7 +244,7 @@ class AdapterSocket extends EventEmitter implements SnapshotSourceSocket { ok: true, pid: this.readResponsePid(), generation: request.generation, - truncated: false, + truncated: request.maxDepth === 1, automationEnabled: true, tree: { XC_kAXXCAttributeElementType: 'Application', diff --git a/packages/platform-apple/src/snapshot-source/adapter.ts b/packages/platform-apple/src/snapshot-source/adapter.ts index 4ebec49b8b..1a6f60de16 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.ts @@ -247,7 +247,7 @@ function truncationResidue( if (nodeCount >= limits.maxNodes) { return { kind: 'truncated', dimension: 'nodes', limit: limits.maxNodes }; } - if (maxTraversalDepth >= maxDepth) { + if (maxTraversalDepth >= Math.max(0, maxDepth - 1)) { return { kind: 'truncated', dimension: 'depth', limit: maxDepth }; } return { kind: 'truncated', dimension: 'payload', limit: limits.maxResponseBytes }; diff --git a/packages/platform-apple/src/snapshot-source/cache-identity.ts b/packages/platform-apple/src/snapshot-source/cache-identity.ts index 8cb5ca9afa..0d1fd09f89 100644 --- a/packages/platform-apple/src/snapshot-source/cache-identity.ts +++ b/packages/platform-apple/src/snapshot-source/cache-identity.ts @@ -17,10 +17,13 @@ export const SNAPSHOT_BRIDGE_SOURCE_FILENAMES = [ 'SnapshotBridge.m', 'SnapshotBridgeRuntime.m', 'SnapshotBridgeRuntime.h', + 'SnapshotBridgeCapture.h', + 'SnapshotBridgeCapture.m', ] as const; export const SNAPSHOT_BRIDGE_COMPILE_FILENAMES = [ 'SnapshotBridge.m', 'SnapshotBridgeRuntime.m', + 'SnapshotBridgeCapture.m', ] as const; export async function fingerprintSnapshotBridgeSource( diff --git a/packages/platform-apple/src/snapshot-source/cache.test.ts b/packages/platform-apple/src/snapshot-source/cache.test.ts index 6042f731bf..4ac7331930 100644 --- a/packages/platform-apple/src/snapshot-source/cache.test.ts +++ b/packages/platform-apple/src/snapshot-source/cache.test.ts @@ -19,6 +19,8 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt await writeFile(sourceFile, 'native source v1'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.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'); let builds = 0; let xcodeVersion = 'Xcode 16.4\nBuild version 16F6'; @@ -127,6 +129,8 @@ test('concurrent snapshot bridge preparation publishes one cache entry', async ( await writeFile(path.join(sourceRoot, 'SnapshotBridge.m'), 'native source'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header'); + await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header'); + await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header'); let builds = 0; const host = createFakeBuildHost(async () => { builds += 1; @@ -163,6 +167,8 @@ test('an aborted cache waiter does not cancel an independent preparation', async await writeFile(path.join(sourceRoot, 'SnapshotBridge.m'), 'native source'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header'); + await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header'); + await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header'); let builds = 0; let buildStarted!: () => void; const started = new Promise((resolve) => { 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 752841e13c..949d1020a0 100644 --- a/packages/platform-apple/src/snapshot-source/fixtures/foreground-owner.m +++ b/packages/platform-apple/src/snapshot-source/fixtures/foreground-owner.m @@ -6,6 +6,7 @@ static id primaryApplication; static id replacementApplication; static NSUInteger captureCount; +static NSString *captureScenario; @interface AXElement : NSObject @property(nonatomic) pid_t pid; @@ -36,6 +37,50 @@ - (instancetype)initForRemoteAccess { return [super init]; } - (id)userTestingSnapshotForElement:(id)element options:(NSDictionary *)options error:(NSError **)error { captureCount++; + if ([captureScenario isEqual:@"unavailable"]) { + if (error) *error = [NSError errorWithDomain:@"unavailable" code:5 + userInfo:@{NSLocalizedDescriptionKey:@"Error kAXErrorIllegalArgument"}]; + return nil; + } + if ([captureScenario hasPrefix:@"wide-"] || [captureScenario isEqual:@"zero-depth"]) { + NSMutableArray *children = [NSMutableArray array]; + NSUInteger limit = [options[@"maxChildren"] unsignedIntegerValue]; + if ([captureScenario isEqual:@"wide-continuation"] && captureCount == 1) limit = 0; + for (NSUInteger i = 0; i < MIN(10, limit); i++) { + [children addObject:@{@"UIAccessibilitySnapshotKeyAttributes": @{@2: @(i).stringValue}, + @"UIAccessibilitySnapshotKeyChildren": @[]}]; + } + return @{@"UIAccessibilitySnapshotKeyAttributes": @{@2: @"fixture app"}, + @"UIAccessibilitySnapshotKeyElement": element, + @"UIAccessibilitySnapshotKeyChildrenCount": @10, + @"UIAccessibilitySnapshotKeyChildren": children}; + } + if ([captureScenario hasPrefix:@"depth-"]) { + NSUInteger requested = [options[@"maxDepth"] unsignedIntegerValue]; + if (requested > 4) { + if (error) *error = [NSError errorWithDomain:@"AX" code:-25201 userInfo:@{@"accessibility-error": @(-25201)}]; + if ([captureScenario isEqual:@"depth-wrapper"]) { + if (error) *error = [NSError errorWithDomain:@"com.apple.dt.xctest.automation-support.error" code:5 userInfo:nil]; + } + return nil; + } + NSUInteger level = [element isKindOfClass:NSNumber.class] ? [element unsignedIntegerValue] : 0; + NSMutableDictionary *tree = nil; + for (NSInteger i = MIN(level + requested, 7) - 1; i >= (NSInteger)level; i--) { + tree = [@{@"UIAccessibilitySnapshotKeyAttributes": @{@2: @(i).stringValue}, + @"UIAccessibilitySnapshotKeyElement": @(i), + @"UIAccessibilitySnapshotKeyChildrenCount": @(i < 6 ? 1 : 0), + @"UIAccessibilitySnapshotKeyChildren": tree ? @[tree] : @[]} mutableCopy]; + } + if ([captureScenario isEqual:@"depth-missing-element"]) { + NSMutableDictionary *frontier = tree; + while ([frontier[@"UIAccessibilitySnapshotKeyChildren"] count]) frontier = [frontier[@"UIAccessibilitySnapshotKeyChildren"] firstObject]; + [frontier removeObjectForKey:@"UIAccessibilitySnapshotKeyElement"]; + } + if ([captureScenario isEqual:@"depth-incomplete"] && level > 0) tree[@"UIAccessibilitySnapshotKeyChildren"] = @[]; + if ([captureScenario isEqual:@"depth-owner-change"] && level > 0) primaryApplication = replacementApplication; + return tree; + } if (replacementApplication) primaryApplication = replacementApplication; return @{ @"UIAccessibilitySnapshotKeyAttributes": @{ @2: @"fixture app" }, @"UIAccessibilitySnapshotKeyChildren": @[] }; @@ -85,6 +130,7 @@ int main(int argc, const char *argv[]) @autoreleasepool { require(argc == 2, @"one capture scenario is required"); NSString *scenario = @(argv[1]); + captureScenario = scenario; AXElement *target = [AXElement new]; target.pid = 42; AXElement *system = [AXElement new]; @@ -93,7 +139,22 @@ int main(int argc, const char *argv[]) primaryApplication = target; NSString *expectedCode = nil; NSUInteger expectedCaptures = 0; - if ([scenario isEqualToString:@"stable"]) { + if ([scenario hasPrefix:@"wide-"] || [scenario isEqual:@"zero-depth"]) { + expectedCaptures = [scenario isEqual:@"wide-continuation"] ? 2 : 1; + } else if ([scenario hasPrefix:@"depth-"]) { + expectedCaptures = [scenario isEqual:@"depth-bound"] ? 1 : [scenario isEqual:@"depth-nodes"] ? 2 : 3; + if ([scenario isEqual:@"depth-missing-element"] || [scenario isEqual:@"depth-incomplete"]) { + expectedCode = @"application-server-unavailable"; + if ([scenario isEqual:@"depth-missing-element"]) expectedCaptures = 2; + } + if ([scenario isEqual:@"depth-owner-change"]) { + replacementApplication = system; + expectedCode = @"foreground-owner-changed"; + } + } else if ([scenario isEqual:@"unavailable"]) { + expectedCaptures = 1; + expectedCode = @"application-server-unavailable"; + } else if ([scenario isEqualToString:@"stable"]) { expectedCaptures = 1; } else if ([scenario isEqualToString:@"changed"]) { replacementApplication = system; @@ -110,16 +171,32 @@ int main(int argc, const char *argv[]) BridgeRuntime *runtime = [[FixtureRuntime alloc] initWithError:&setupError]; require(runtime != nil, setupError ?: @"fixture initialization failed"); NSDictionary *error = nil; - NSDictionary *result = [runtime snapshotForProcess:42 maxDepth:8 maxNodes:10 + NSDictionary *result = [runtime snapshotForProcess:42 maxDepth:([scenario isEqual:@"zero-depth"] ? 0 : [scenario isEqual:@"depth-bound"] ? 4 : 8) maxNodes:(([scenario isEqual:@"depth-nodes"] || [scenario hasPrefix:@"wide-"]) ? 3 : 10) 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:@"unsupported"], @"refusal must preserve the typed failure kind"); + require([error[@"error_kind"] isEqual:([expectedCode isEqual:@"application-server-unavailable"] ? @"application_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 { require(error == nil && [result[@"ok"] boolValue], @"stable foreground must publish successfully"); - require([result[@"tree"][@"XC_kAXXCAttributeLabel"] isEqual:@"fixture app"], @"stable capture must publish the materialized app tree"); + if ([scenario hasPrefix:@"wide-"] || [scenario isEqual:@"zero-depth"]) { + require([result[@"truncated"] boolValue], @"bounded capture must disclose omitted content"); + NSArray *children = result[@"tree"][@"XC_kAXXCAttributeChildren"]; + require(children.count == ([scenario isEqual:@"zero-depth"] ? 0 : 2), @"bounded capture must retain the permitted children"); + if (children.count) require([children[1][@"XC_kAXXCAttributeLabel"] isEqual:@"1"], @"bounded capture must preserve sibling order"); + } else if ([scenario isEqual:@"depth-bound"] || [scenario isEqual:@"depth-nodes"]) { + require([result[@"truncated"] boolValue], @"bounded capture must disclose omitted content"); + NSDictionary *node = result[@"tree"]; + NSUInteger count = 1; + while ([node[@"XC_kAXXCAttributeChildren"] count]) {node = [node[@"XC_kAXXCAttributeChildren"] firstObject]; count++;} + require(count == ([scenario isEqual:@"depth-bound"] ? 4 : 3), @"bounded capture must retain every allowed node"); + } else if ([scenario hasPrefix:@"depth-"]) { + NSDictionary *node = result[@"tree"]; + for (NSUInteger i = 0; i < 6; i++) node = [node[@"XC_kAXXCAttributeChildren"] firstObject]; + require([node[@"XC_kAXXCAttributeLabel"] isEqual:@"6"], @"recovery must retain the deepest content"); + require(![result[@"truncated"] boolValue], @"complete recovered tree must remain complete"); + } else require([result[@"tree"][@"XC_kAXXCAttributeLabel"] isEqual:@"fixture app"], @"stable capture must publish the materialized app tree"); } require(captureCount == expectedCaptures, @"covered apps must be refused before native acquisition"); } diff --git a/packages/platform-apple/src/snapshot-source/fixtures/wire-vocabulary.json b/packages/platform-apple/src/snapshot-source/fixtures/wire-vocabulary.json index b7282ec1d6..fb88c09d80 100644 --- a/packages/platform-apple/src/snapshot-source/fixtures/wire-vocabulary.json +++ b/packages/platform-apple/src/snapshot-source/fixtures/wire-vocabulary.json @@ -1,6 +1,6 @@ { "protocolVersion": 1, - "sourceVersion": "agent-device-simulator-ax-v1.5.3", + "sourceVersion": "agent-device-simulator-ax-v1.5.4", "requestKeys": [ "verb", "requestId", diff --git a/packages/platform-apple/src/snapshot-source/native-runtime.test.ts b/packages/platform-apple/src/snapshot-source/native-runtime.test.ts index bce7ac5cab..a8384a39f5 100644 --- a/packages/platform-apple/src/snapshot-source/native-runtime.test.ts +++ b/packages/platform-apple/src/snapshot-source/native-runtime.test.ts @@ -4,7 +4,7 @@ import { beforeAll, describe, test } from 'vitest'; import { runCmd } from '@agent-device/host-kit/command'; import { mkdtempForTest } from '../__tests__/tmp-dir.ts'; -describe.skipIf(process.platform !== 'darwin')('native snapshot foreground ownership', () => { +describe.skipIf(process.platform !== 'darwin')('native snapshot capture', () => { let binary: string; beforeAll(async () => { binary = path.join(await mkdtempForTest('snapshot-foreground-'), 'foreground-owner'); @@ -25,6 +25,7 @@ describe.skipIf(process.platform !== 'darwin')('native snapshot foreground owner '-I', nativeRoot, path.join(nativeRoot, 'SnapshotBridgeRuntime.m'), + path.join(nativeRoot, 'SnapshotBridgeCapture.m'), path.join(import.meta.dirname, 'fixtures/foreground-owner.m'), '-o', binary, @@ -34,11 +35,25 @@ describe.skipIf(process.platform !== 'darwin')('native snapshot foreground owner assert.equal(compiled.exitCode, 0, compiled.stderr); }, 60_000); - test.each(['stable', 'covered', 'changed', 'missing', 'malformed'])( - 'snapshot capture enforces %s foreground ownership', - async (scenario) => { - const result = await runCmd(binary, [scenario], { allowFailure: true, timeoutMs: 5_000 }); - assert.equal(result.exitCode, 0, result.stderr); - }, - ); + test.each([ + 'stable', + 'wide-nodes', + 'wide-continuation', + 'zero-depth', + 'covered', + 'changed', + 'missing', + 'malformed', + 'depth-recovery', + 'depth-wrapper', + 'depth-missing-element', + 'depth-incomplete', + 'depth-bound', + 'depth-nodes', + 'depth-owner-change', + 'unavailable', + ])('snapshot capture enforces %s', async (scenario) => { + const result = await runCmd(binary, [scenario], { allowFailure: true, timeoutMs: 5_000 }); + assert.equal(result.exitCode, 0, result.stderr); + }); }); diff --git a/packages/platform-apple/src/snapshot-source/protocol.test.ts b/packages/platform-apple/src/snapshot-source/protocol.test.ts index 1f96a6748e..4c8f7f7c90 100644 --- a/packages/platform-apple/src/snapshot-source/protocol.test.ts +++ b/packages/platform-apple/src/snapshot-source/protocol.test.ts @@ -122,7 +122,13 @@ test('snapshot bridge failures stay typed at the guest boundary', () => { test('wire vocabulary guard keeps TS and Objective-C literals aligned', async () => { const native = await Promise.all( - ['SnapshotBridge.m', 'SnapshotBridgeRuntime.m', 'SnapshotBridgeRuntime.h'].map((fileName) => + [ + 'SnapshotBridge.m', + 'SnapshotBridgeRuntime.m', + 'SnapshotBridgeRuntime.h', + 'SnapshotBridgeCapture.h', + 'SnapshotBridgeCapture.m', + ].map((fileName) => readFile( path.join(import.meta.dirname, '../../../../apple/snapshot-bridge', fileName), 'utf8', @@ -136,7 +142,7 @@ test('wire vocabulary guard keeps TS and Objective-C literals aligned', async () 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\.5\.3"/); + assert.match(nativeSource, /kSourceVersion = @"agent-device-simulator-ax-v1\.5\.4"/); 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 e4f0f47748..e6140257e7 100644 --- a/packages/platform-apple/src/snapshot-source/protocol.ts +++ b/packages/platform-apple/src/snapshot-source/protocol.ts @@ -3,7 +3,7 @@ import { snapshotSourceError } from './errors.ts'; import type { SnapshotSourceLimits } from './types.ts'; export const SNAPSHOT_SOURCE_PROTOCOL_VERSION = 1; -export const SNAPSHOT_SOURCE_VERSION = 'agent-device-simulator-ax-v1.5.3'; +export const SNAPSHOT_SOURCE_VERSION = 'agent-device-simulator-ax-v1.5.4'; const FRAME_HEADER_BYTES = 4; export const SNAPSHOT_SOURCE_WIRE_KEYS = Object.freeze([ From 0e385fd225d0af451ab1607196e1979426b667da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 9 Sep 2026 11:01:40 +0200 Subject: [PATCH 2/6] chore(gates): enforce snapshot assets and optional probe lifecycle --- .github/workflows/ios.yml | 16 +++++++++++++++- .../__tests__/fixtures/size-report-npm-pack.json | 2 ++ .../ios-snapshot-benchmark/size-install.test.ts | 2 ++ scripts/size-report-package.mjs | 2 ++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 921403489a..b4d64cbb50 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -147,6 +147,7 @@ jobs: - name: Run targeted iOS runner XCTest regressions run: | + set -o pipefail XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)" test -n "$XCTESTRUN_PATH" xcodebuild test-without-building \ @@ -164,6 +165,12 @@ jobs: -only-testing:AgentDeviceRunnerUITests/RunnerTests/testEmptyReplacementWithoutResolvableTargetFailsClosed \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testTextEntryTapWitnessIsBoundToTargetIdentity \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testCoordinateTapTextInputProbeSkipsPenalizedXCTestChannel \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testFreshCoordinateTapContainsUnavailableTextInputProbe \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testTextInputProbeIssueScopeIsThreadBound \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testTextInputProbePreservesEnclosingRunnerWait \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSuppressedAxIssueMakesTextInputProbeUnavailable \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testHealthyCoordinateTapPreservesBareTypingWitness \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testTextInputProbeContainmentExcludesRequiredReadsAndLaterIssues \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testCoordinateTextInputCandidateMustBeEnabledAndContainTheTouchPoint \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testQuerySelectorPrefersHittableMatchOverNonHittableDuplicate \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testActivateTargetSkipsForegroundAndActivatesNonForegroundApplication \ @@ -234,7 +241,14 @@ jobs: -only-testing:AgentDeviceRunnerUITests/RunnerTests/testCustomActionCoverageParsesOnlyCompletePairs \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testPartialCustomActionPassIsDisclosedAndCompleteOneIsNot \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testActionNamesAreCappedPerElementAndReported \ - -only-testing:AgentDeviceRunnerUITests/RunnerTests/testHungCustomActionReadIsContainedAndRecovers + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testHungCustomActionReadIsContainedAndRecovers 2>&1 | tee /tmp/agent-device-runner-regressions.log + node --input-type=module -e ' + import { readFileSync } from "node:fs"; + const log = readFileSync("/tmp/agent-device-runner-regressions.log", "utf8"); + if (!/\] AGENT_DEVICE_RUNNER_OPTIONAL_PROBE_WAIT_COMPLETED$/m.test(log)) { + throw new Error("Optional observation ended the runner test before its wait completed"); + } + ' - name: Preflight iOS runner through public CLI run: | diff --git a/scripts/__tests__/fixtures/size-report-npm-pack.json b/scripts/__tests__/fixtures/size-report-npm-pack.json index df8c38abe9..5d5cf29e20 100644 --- a/scripts/__tests__/fixtures/size-report-npm-pack.json +++ b/scripts/__tests__/fixtures/size-report-npm-pack.json @@ -8,6 +8,8 @@ { "path": "apple/snapshot-bridge/SnapshotBridge.m", "size": 0 }, { "path": "apple/snapshot-bridge/SnapshotBridgeRuntime.m", "size": 0 }, { "path": "apple/snapshot-bridge/SnapshotBridgeRuntime.h", "size": 0 }, + { "path": "apple/snapshot-bridge/SnapshotBridgeCapture.h", "size": 0 }, + { "path": "apple/snapshot-bridge/SnapshotBridgeCapture.m", "size": 0 }, { "path": "apple/macos-helper/Sources/main.swift", "size": 211 }, { "path": "android/snapshot-helper/dist/helper.apk", "size": 307 }, { "path": "android/snapshot-helper/dist/helper.manifest.json", "size": 99 }, diff --git a/scripts/ios-snapshot-benchmark/size-install.test.ts b/scripts/ios-snapshot-benchmark/size-install.test.ts index cd3ed6f21e..770461afe5 100644 --- a/scripts/ios-snapshot-benchmark/size-install.test.ts +++ b/scripts/ios-snapshot-benchmark/size-install.test.ts @@ -57,6 +57,8 @@ test('clean-installed snapshot bridge validates all native assets when present', await writeFile(join(bridge, 'SnapshotBridge.m'), 'native source'); await writeFile(join(bridge, 'SnapshotBridgeRuntime.m'), 'native runtime'); await writeFile(join(bridge, 'SnapshotBridgeRuntime.h'), 'native header'); + await writeFile(join(bridge, 'SnapshotBridgeCapture.h'), 'capture header'); + await writeFile(join(bridge, 'SnapshotBridgeCapture.m'), 'capture source'); assert.doesNotThrow(() => assertInstalledSnapshotBridge(root)); await rm(join(bridge, 'SnapshotBridgeRuntime.h')); assert.throws(() => assertInstalledSnapshotBridge(root), /SnapshotBridgeRuntime\.h/); diff --git a/scripts/size-report-package.mjs b/scripts/size-report-package.mjs index cc4693e46a..113b2651e0 100644 --- a/scripts/size-report-package.mjs +++ b/scripts/size-report-package.mjs @@ -5,6 +5,8 @@ export const SNAPSHOT_BRIDGE_ASSET_PATHS = Object.freeze([ 'apple/snapshot-bridge/SnapshotBridge.m', 'apple/snapshot-bridge/SnapshotBridgeRuntime.m', 'apple/snapshot-bridge/SnapshotBridgeRuntime.h', + 'apple/snapshot-bridge/SnapshotBridgeCapture.h', + 'apple/snapshot-bridge/SnapshotBridgeCapture.m', ]); export function assertSnapshotBridgeAssets(presentPaths, context) { From e55a841fdd2c5cd4c3a3dd6f60935f67cfd59981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 9 Sep 2026 11:39:12 +0200 Subject: [PATCH 3/6] fix(ios): preserve capture bounds and local probe recovery --- .../RunnerTests+TextInputProbe.swift | 3 +- .../RunnerTests+TextInputProbeTests.swift | 11 +++- apple/snapshot-bridge/README.md | 8 ++- apple/snapshot-bridge/SnapshotBridgeCapture.m | 27 +++++--- apple/snapshot-bridge/SnapshotBridgeRuntime.m | 7 ++ .../src/snapshot-source/adapter.test.ts | 5 +- .../src/snapshot-source/adapter.ts | 2 +- .../fixtures/foreground-owner.m | 64 ++++++++++++++++--- .../snapshot-source/native-runtime.test.ts | 8 +++ 9 files changed, 110 insertions(+), 25 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextInputProbe.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextInputProbe.swift index 5fb209a6aa..70c9d6530f 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextInputProbe.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextInputProbe.swift @@ -33,8 +33,7 @@ extension RunnerTests { return elements.first case .absent: return nil - case .unavailable(let failure): - penalizeSnapshotXCTestChannel(bundleId: currentBundleId, reason: failure.rawValue) + case .unavailable: return nil } } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextInputProbeTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextInputProbeTests.swift index 8e402dd12b..fd1a0d51c9 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextInputProbeTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextInputProbeTests.swift @@ -151,12 +151,21 @@ extension RunnerTests { let response = try execute(command: command) XCTAssertTrue(response.ok, String(describing: response.error)) XCTAssertFalse(didRecordXCTestFailure(since: failures)) - XCTAssertTrue(isSnapshotXCTestChannelPenalized(bundleId: currentBundleId)) + XCTAssertFalse(isSnapshotXCTestChannelPenalized(bundleId: currentBundleId)) XCTAssertNil(textEntryTapWitness) let type = try runnerCommandFixture(#"{"appBundleId":"com.callstack.agentdevice.runner","command":"type","commandId":"type-after-unavailable-probe","text":"must-not-type"}"#) let typed = try execute(command: type) XCTAssertFalse(typed.ok) XCTAssertEqual(typed.error?.code, "TEXT_INPUT_NOT_FOCUSED") + let field = app.textFields["agent-device-hardware-keyboard-input"] + let fieldFrame = field.frame + let nextTap = try runnerCommandFixture( + #"{"appBundleId":"com.callstack.agentdevice.runner","command":"tap","commandId":"tap-after-probe-recovery","x":\#(fieldFrame.midX),"y":\#(fieldFrame.midY),"synthesized":true}"# + ) + XCTAssertTrue(try execute(command: nextTap).ok) + XCTAssertNotNil(textEntryTapWitness) + XCTAssertTrue(try execute(command: type).ok) + XCTAssertEqual(field.value as? String, "must-not-type") } #endif } diff --git a/apple/snapshot-bridge/README.md b/apple/snapshot-bridge/README.md index 1c182f5085..d3ca05184c 100644 --- a/apple/snapshot-bridge/README.md +++ b/apple/snapshot-bridge/README.md @@ -54,6 +54,10 @@ A healthy capture uses one native request. If native acquisition rejects it, `SnapshotBridgeCapture.m` retries supported native failure codes at lower depths and fetches withheld children from their accessibility elements. The completed tree keeps the original depth and node limits; partial trees disclose truncation. -Recovery allows at most 32 native requests within the existing capture deadline, +The traversal depth counts edges below the root; native requests count the root +as one level. Each acquisition allows two lower-depth retries, and recovery +allows at most 32 native requests within the existing capture deadline, checks foreground ownership on every request, and returns a failure when it -cannot complete a continuation. The route then retains its XCTest fallback. +cannot complete a continuation. Budget exhaustion and malformed continuations +use non-launch failure codes, so the route falls back without launch re-polling. +Unchanged native dictionaries and child arrays are reused. diff --git a/apple/snapshot-bridge/SnapshotBridgeCapture.m b/apple/snapshot-bridge/SnapshotBridgeCapture.m index 321650f421..237966b09a 100644 --- a/apple/snapshot-bridge/SnapshotBridgeCapture.m +++ b/apple/snapshot-bridge/SnapshotBridgeCapture.m @@ -21,7 +21,7 @@ @implementation SnapshotTreeCapture - (nullable NSDictionary *)read:(id)element depth:(NSUInteger)depth error:(NSError **)error { NSUInteger attemptDepth = MIN(depth, self.acceptedDepth); - for (;;) { + for (NSUInteger retries = 0;; retries++) { if (self.requests >= maximumRequests) { if (error) *error = [NSError errorWithDomain:@"agent-device.snapshot" code:1 userInfo:@{NSLocalizedDescriptionKey: @"snapshot continuation request budget exhausted"}]; @@ -34,7 +34,7 @@ - (nullable NSDictionary *)read:(id)element depth:(NSUInteger)depth error:(NSErr NSNumber *nativeCode = failure.userInfo[@"accessibility-error"]; BOOL rejected = ([nativeCode isKindOfClass:NSNumber.class] && nativeCode.integerValue == -25201) || ([failure.domain isEqualToString:@"com.apple.dt.xctest.automation-support.error"] && failure.code == 5); - if (!rejected || attemptDepth <= 1) { + if (!rejected || attemptDepth <= 1 || retries >= 2) { if (error) *error = failure; return nil; } @@ -60,11 +60,12 @@ - (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)de NSArray *children = tree[childrenKey]; NSNumber *childCount = tree[childCountKey]; BOOL withheld = [childCount isKindOfClass:NSNumber.class] && childCount.unsignedIntegerValue > children.count; - NSMutableDictionary *result = [tree mutableCopy]; if (depth <= 1 || self.remainingNodes == 0) { self.truncated |= children.count > 0 || withheld; - result[childrenKey] = @[]; - return result; + if (children.count == 0) return tree; + NSMutableDictionary *bounded = [tree mutableCopy]; + bounded[childrenKey] = @[]; + return bounded; } if (withheld && children.count < self.remainingNodes) { id element = tree[elementKey]; @@ -83,17 +84,23 @@ - (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)de } } if (withheld && children.count < childCount.unsignedIntegerValue) self.truncated = YES; - NSMutableArray *materialized = [NSMutableArray array]; + NSMutableArray *materialized = nil; + NSUInteger index = 0; for (NSDictionary *child in children) { if (self.remainingNodes == 0) { self.truncated = YES; + if (!materialized) materialized = [[children subarrayWithRange:NSMakeRange(0, index)] mutableCopy]; break; } NSDictionary *node = [self materialize:child depth:depth - 1 error:error]; if (!node) return nil; + if (node != child && !materialized) materialized = [[children subarrayWithRange:NSMakeRange(0, index)] mutableCopy]; [materialized addObject:node]; + index++; } - result[childrenKey] = materialized; + if (!materialized && children == tree[childrenKey]) return tree; + NSMutableDictionary *result = [tree mutableCopy]; + result[childrenKey] = materialized ?: children; return result; } @end @@ -103,11 +110,11 @@ - (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)de { SnapshotTreeCapture *capture = [SnapshotTreeCapture new]; capture.reader = reader; - capture.acceptedDepth = maxDepth; + capture.acceptedDepth = maxDepth + 1; capture.remainingNodes = maxNodes; capture.maximumNodes = maxNodes; - NSDictionary *tree = [capture read:element depth:maxDepth error:error]; - NSDictionary *result = tree ? [capture materialize:tree depth:MAX(1, maxDepth) error:error] : nil; + NSDictionary *tree = [capture read:element depth:maxDepth + 1 error:error]; + NSDictionary *result = tree ? [capture materialize:tree depth:maxDepth + 1 error:error] : nil; *truncated = capture.truncated; return result; } diff --git a/apple/snapshot-bridge/SnapshotBridgeRuntime.m b/apple/snapshot-bridge/SnapshotBridgeRuntime.m index b92ba60950..2d27591d55 100644 --- a/apple/snapshot-bridge/SnapshotBridgeRuntime.m +++ b/apple/snapshot-bridge/SnapshotBridgeRuntime.m @@ -342,6 +342,13 @@ - (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); + finishRequestWatchdog(watchdog, watchdogState); + return nil; + } if (!snapshot) { NSNumber *axError = runtimeError.userInfo[kAccessibilityErrorKey]; NSInteger code = [axError respondsToSelector:@selector(integerValue)] ? axError.integerValue : runtimeError.code; diff --git a/packages/platform-apple/src/snapshot-source/adapter.test.ts b/packages/platform-apple/src/snapshot-source/adapter.test.ts index ec63e6416d..7e5de38c62 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.test.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.test.ts @@ -249,7 +249,10 @@ class AdapterSocket extends EventEmitter implements SnapshotSourceSocket { tree: { XC_kAXXCAttributeElementType: 'Application', XC_kAXXCAttributeFrame: { X: 0, Y: 0, Width: 390, Height: 844 }, - XC_kAXXCAttributeChildren: [], + XC_kAXXCAttributeChildren: + request.maxDepth === 1 + ? [{ XC_kAXXCAttributeElementType: 'Button', XC_kAXXCAttributeChildren: [] }] + : [], }, }, { diff --git a/packages/platform-apple/src/snapshot-source/adapter.ts b/packages/platform-apple/src/snapshot-source/adapter.ts index 1a6f60de16..4ebec49b8b 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.ts @@ -247,7 +247,7 @@ function truncationResidue( if (nodeCount >= limits.maxNodes) { return { kind: 'truncated', dimension: 'nodes', limit: limits.maxNodes }; } - if (maxTraversalDepth >= Math.max(0, maxDepth - 1)) { + if (maxTraversalDepth >= maxDepth) { return { kind: 'truncated', dimension: 'depth', limit: maxDepth }; } return { kind: 'truncated', dimension: 'payload', limit: limits.maxResponseBytes }; 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 949d1020a0..73e61876bf 100644 --- a/packages/platform-apple/src/snapshot-source/fixtures/foreground-owner.m +++ b/packages/platform-apple/src/snapshot-source/fixtures/foreground-owner.m @@ -1,4 +1,5 @@ #import "SnapshotBridgeRuntime.h" +#import "SnapshotBridgeCapture.h" #import #import @@ -37,6 +38,18 @@ - (instancetype)initForRemoteAccess { return [super init]; } - (id)userTestingSnapshotForElement:(id)element options:(NSDictionary *)options error:(NSError **)error { captureCount++; + if ([captureScenario isEqual:@"runtime-budget"]) { + BOOL root = ![element isKindOfClass:NSNumber.class]; + NSMutableArray *children = [NSMutableArray array]; + for (NSUInteger i = 0; i < (root ? 40 : 1); i++) [children addObject:@{ + @"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildren": @[], + @"UIAccessibilitySnapshotKeyElement": @(i), @"UIAccessibilitySnapshotKeyChildrenCount": @(root ? 1 : 0)}]; + return @{@"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildren": children}; + } + if ([captureScenario isEqual:@"rejected"]) { + if (error) *error = [NSError errorWithDomain:@"AX" code:-25201 userInfo:@{@"accessibility-error": @(-25201)}]; + return nil; + } if ([captureScenario isEqual:@"unavailable"]) { if (error) *error = [NSError errorWithDomain:@"unavailable" code:5 userInfo:@{NSLocalizedDescriptionKey:@"Error kAXErrorIllegalArgument"}]; @@ -130,6 +143,38 @@ int main(int argc, const char *argv[]) @autoreleasepool { require(argc == 2, @"one capture scenario is required"); NSString *scenario = @(argv[1]); + if ([scenario isEqual:@"identity"] || [scenario isEqual:@"request-budget"] || [scenario hasPrefix:@"api-depth-"]) { + BOOL budget = [scenario isEqual:@"request-budget"]; + NSUInteger depth = [scenario hasPrefix:@"api-depth-"] ? [[scenario substringFromIndex:10] integerValue] : 64; + NSMutableArray *children = [NSMutableArray array]; + for (NSUInteger i = 0; i < (budget ? 40 : 2); i++) { + [children addObject:@{@"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildren": @[], + @"UIAccessibilitySnapshotKeyElement": @(i), @"UIAccessibilitySnapshotKeyChildrenCount": @(budget ? 1 : 0)}]; + } + NSDictionary *root = @{@"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildren": children}; + __block NSUInteger requests = 0; + BOOL truncated = NO; + NSError *failure = nil; + NSDictionary *result = captureSnapshotTree(@"root", depth, 1000, ^id(id element, NSUInteger levels, NSUInteger nodes, NSError **error) { + requests++; + if ([scenario hasPrefix:@"api-depth-"]) { + require(levels == depth + 1, @"native levels must include the root exactly once"); + NSDictionary *tree = nil; + for (NSUInteger i = 0; i < levels; i++) tree = @{@"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildren": tree ? @[tree] : @[]}; + return tree; + } + if ([element isEqual:@"root"]) return root; + return @{@"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildren": @[@{@"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildren": @[]}]}; + }, &truncated, &failure); + if (budget) require(!result && failure.code == 1 && requests == 32, @"request budget must fail without publishing partial content"); + else if ([scenario isEqual:@"identity"]) require(result == root && requests == 1, @"healthy capture must reuse the native tree"); + else { + NSUInteger count = 0; + for (NSDictionary *node = result; node; node = [node[@"UIAccessibilitySnapshotKeyChildren"] firstObject]) count++; + require(count == depth + 1 && !truncated && requests == 1, @"every requested depth must include root plus permitted descendants"); + } + return 0; + } captureScenario = scenario; AXElement *target = [AXElement new]; target.pid = 42; @@ -139,20 +184,23 @@ int main(int argc, const char *argv[]) primaryApplication = target; NSString *expectedCode = nil; NSUInteger expectedCaptures = 0; - if ([scenario hasPrefix:@"wide-"] || [scenario isEqual:@"zero-depth"]) { + if ([scenario isEqual:@"runtime-budget"]) { + expectedCaptures = 32; + expectedCode = @"continuation-budget-exhausted"; + } else if ([scenario hasPrefix:@"wide-"] || [scenario isEqual:@"zero-depth"]) { expectedCaptures = [scenario isEqual:@"wide-continuation"] ? 2 : 1; } else if ([scenario hasPrefix:@"depth-"]) { - expectedCaptures = [scenario isEqual:@"depth-bound"] ? 1 : [scenario isEqual:@"depth-nodes"] ? 2 : 3; + expectedCaptures = [scenario isEqual:@"depth-bound"] ? 5 : [scenario isEqual:@"depth-nodes"] ? 2 : 3; if ([scenario isEqual:@"depth-missing-element"] || [scenario isEqual:@"depth-incomplete"]) { - expectedCode = @"application-server-unavailable"; + expectedCode = @"snapshot-tree-malformed"; if ([scenario isEqual:@"depth-missing-element"]) expectedCaptures = 2; } if ([scenario isEqual:@"depth-owner-change"]) { replacementApplication = system; expectedCode = @"foreground-owner-changed"; } - } else if ([scenario isEqual:@"unavailable"]) { - expectedCaptures = 1; + } else if ([scenario isEqual:@"unavailable"] || [scenario isEqual:@"rejected"]) { + expectedCaptures = [scenario isEqual:@"rejected"] ? 3 : 1; expectedCode = @"application-server-unavailable"; } else if ([scenario isEqualToString:@"stable"]) { expectedCaptures = 1; @@ -171,11 +219,11 @@ int main(int argc, const char *argv[]) BridgeRuntime *runtime = [[FixtureRuntime alloc] initWithError:&setupError]; require(runtime != nil, setupError ?: @"fixture initialization failed"); NSDictionary *error = nil; - NSDictionary *result = [runtime snapshotForProcess:42 maxDepth:([scenario isEqual:@"zero-depth"] ? 0 : [scenario isEqual:@"depth-bound"] ? 4 : 8) maxNodes:(([scenario isEqual:@"depth-nodes"] || [scenario hasPrefix:@"wide-"]) ? 3 : 10) + NSDictionary *result = [runtime snapshotForProcess:42 maxDepth:([scenario isEqual:@"zero-depth"] ? 0 : [scenario isEqual:@"depth-bound"] ? 4 : 8) maxNodes:(([scenario isEqual:@"depth-nodes"] || [scenario hasPrefix:@"wide-"]) ? 3 : [scenario isEqual:@"runtime-budget"] ? 1000 : 10) 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" : @"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"] ? @"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 { @@ -190,7 +238,7 @@ int main(int argc, const char *argv[]) NSDictionary *node = result[@"tree"]; NSUInteger count = 1; while ([node[@"XC_kAXXCAttributeChildren"] count]) {node = [node[@"XC_kAXXCAttributeChildren"] firstObject]; count++;} - require(count == ([scenario isEqual:@"depth-bound"] ? 4 : 3), @"bounded capture must retain every allowed node"); + require(count == ([scenario isEqual:@"depth-bound"] ? 5 : 3), @"bounded capture must retain every allowed node"); } else if ([scenario hasPrefix:@"depth-"]) { NSDictionary *node = result[@"tree"]; for (NSUInteger i = 0; i < 6; i++) node = [node[@"XC_kAXXCAttributeChildren"] firstObject]; diff --git a/packages/platform-apple/src/snapshot-source/native-runtime.test.ts b/packages/platform-apple/src/snapshot-source/native-runtime.test.ts index a8384a39f5..051297ac0f 100644 --- a/packages/platform-apple/src/snapshot-source/native-runtime.test.ts +++ b/packages/platform-apple/src/snapshot-source/native-runtime.test.ts @@ -37,6 +37,14 @@ describe.skipIf(process.platform !== 'darwin')('native snapshot capture', () => test.each([ 'stable', + 'identity', + 'request-budget', + 'runtime-budget', + 'api-depth-0', + 'api-depth-1', + 'api-depth-4', + 'api-depth-128', + 'rejected', 'wide-nodes', 'wide-continuation', 'zero-depth', From b908e7e1806d72e4194d16877a1fee06cf85d4c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 9 Sep 2026 11:39:12 +0200 Subject: [PATCH 4/6] chore(gates): validate base package assets with its own policy --- .github/workflows/size.yml | 1 + scripts/__tests__/size-report-package.test.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml index 3dfcebd9ab..a78ce70a19 100644 --- a/.github/workflows/size.yml +++ b/.github/workflows/size.yml @@ -73,6 +73,7 @@ jobs: - name: Measure base size run: | git checkout --detach "${{ github.event.pull_request.base.sha }}" + cp scripts/size-report-package.mjs /tmp/agent-device-size-report/ pnpm install --frozen-lockfile if [ "${{ steps.base-dist-cache.outputs.cache-hit }}" != "true" ]; then pnpm build diff --git a/scripts/__tests__/size-report-package.test.ts b/scripts/__tests__/size-report-package.test.ts index 2f7cf690fe..c3841b27b9 100644 --- a/scripts/__tests__/size-report-package.test.ts +++ b/scripts/__tests__/size-report-package.test.ts @@ -74,3 +74,17 @@ test('Markdown emphasizes total install size and startup without duplicate break /\| Installed \(including dependencies\) \| - \| 350 B \| - \|/, ); }); + +test('base measurement uses the measured revision package asset policy', async () => { + const workflow = await readFile( + join(import.meta.dirname, '../../.github/workflows/size.yml'), + 'utf8', + ); + const baseStep = workflow + .split(' - name: Measure base size\n')[1]! + .split(' - name: Save base dist cache')[0]!; + assert.match( + baseStep, + /git checkout --detach[\s\S]*cp scripts\/size-report-package\.mjs \/tmp\/agent-device-size-report\/[\s\S]*node \/tmp\/agent-device-size-report\/size-report\.mjs/, + ); +}); From 7966e9d81310e2ebe9f820120d559ab437102adf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 9 Sep 2026 14:00:13 +0200 Subject: [PATCH 5/6] chore(gates): verify recovery failures respect launch observation policy --- .../src/snapshot-observability.test.ts | 25 +++++++++++-------- .../platform-apple/src/snapshot-route.test.ts | 8 ++++-- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/platform-apple/src/snapshot-observability.test.ts b/packages/platform-apple/src/snapshot-observability.test.ts index 3993ec6a42..00e4d58c94 100644 --- a/packages/platform-apple/src/snapshot-observability.test.ts +++ b/packages/platform-apple/src/snapshot-observability.test.ts @@ -134,17 +134,20 @@ test('the last poll is capped to the remaining window', async () => { expect(sleeps.reduce((sum, ms) => sum + ms, 0)).toBeLessThanOrEqual(1_000); }); -test('a failure outside the launch transition ends the wait at once', async () => { - const { observe, acquire, sleep } = probe( - [failed('bridge-disconnected', 'transport-failure'), acquired()], - { now: () => 0, sleep: async () => {} }, - ); - await expect(observe.awaitObservable(simulator, 'com.example.app', signal())).resolves.toBe( - 'unobservable', - ); - expect(acquire).toHaveBeenCalledOnce(); - expect(sleep).not.toHaveBeenCalled(); -}); +test.each(['bridge-disconnected', 'continuation-budget-exhausted', 'snapshot-tree-malformed'])( + 'a %s failure ends the launch wait at once', + async (code) => { + const { observe, acquire, sleep } = probe([failed(code, 'transport-failure'), acquired()], { + now: () => 0, + sleep: async () => {}, + }); + await expect(observe.awaitObservable(simulator, 'com.example.app', signal())).resolves.toBe( + 'unobservable', + ); + expect(acquire).toHaveBeenCalledOnce(); + expect(sleep).not.toHaveBeenCalled(); + }, +); test('a generation whose bridge circuit is open is unobservable without a bridge round trip', async () => { const { observe, acquire, sleep, gate } = probe( diff --git a/packages/platform-apple/src/snapshot-route.test.ts b/packages/platform-apple/src/snapshot-route.test.ts index 89cebb51fc..cd099dc100 100644 --- a/packages/platform-apple/src/snapshot-route.test.ts +++ b/packages/platform-apple/src/snapshot-route.test.ts @@ -269,13 +269,17 @@ test('a slow app discovery yields to a live runner within its wait slice, then s } }); -test('an open whose generation already failed the bridge skips the launch-observation poll', async () => { +test.each([ + 'application-server-unavailable', + 'continuation-budget-exhausted', + 'snapshot-tree-malformed', +])('an open whose generation failed with %s skips the launch-observation poll', async (code) => { // #2199: `application-server-unavailable` is a launch-transition code, so an ungated probe would // re-read the bridge every 150 ms for its whole 5 s window on a generation the circuit already // gave up on — ~33 acquisitions per `open`, each a fresh connect. const source = sourceReturning({ stage: 'failed', - failure: { kind: 'transport-failure', code: 'application-server-unavailable' }, + failure: { kind: 'transport-failure', code }, }); const route = createAppleSnapshotRoute( { ...platformRuntimeHostFixture(), clock: steppingClock() }, From 7c84acfe684fbb45191460766ac8e9f6d739aa95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 9 Sep 2026 14:21:49 +0200 Subject: [PATCH 6/6] fix(ios): fail closed on unknown snapshot frontier completeness --- apple/snapshot-bridge/README.md | 4 +++- apple/snapshot-bridge/SnapshotBridgeCapture.m | 19 ++++++++++++++----- .../fixtures/foreground-owner.m | 14 ++++++++------ .../snapshot-source/native-runtime.test.ts | 7 +++++++ 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/apple/snapshot-bridge/README.md b/apple/snapshot-bridge/README.md index d3ca05184c..fb6852eb80 100644 --- a/apple/snapshot-bridge/README.md +++ b/apple/snapshot-bridge/README.md @@ -60,4 +60,6 @@ allows at most 32 native requests within the existing capture deadline, checks foreground ownership on every request, and returns a failure when it cannot complete a continuation. Budget exhaustion and malformed continuations use non-launch failure codes, so the route falls back without launch re-polling. -Unchanged native dictionaries and child arrays are reused. +At each native fragment boundary, an absent or invalid child count means unknown +completeness and fails closed. Natural leaves above that boundary need no +continuation evidence. Unchanged native dictionaries and child arrays are reused. diff --git a/apple/snapshot-bridge/SnapshotBridgeCapture.m b/apple/snapshot-bridge/SnapshotBridgeCapture.m index 237966b09a..857b8bc453 100644 --- a/apple/snapshot-bridge/SnapshotBridgeCapture.m +++ b/apple/snapshot-bridge/SnapshotBridgeCapture.m @@ -14,7 +14,7 @@ @interface SnapshotTreeCapture : NSObject @property(nonatomic) NSUInteger requests; @property(nonatomic) BOOL truncated; - (nullable NSDictionary *)read:(id)element depth:(NSUInteger)depth error:(NSError **)error; -- (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)depth error:(NSError **)error; +- (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)depth nativeLevels:(NSUInteger)nativeLevels error:(NSError **)error; @end @implementation SnapshotTreeCapture @@ -43,7 +43,7 @@ - (nullable NSDictionary *)read:(id)element depth:(NSUInteger)depth error:(NSErr } } -- (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)depth error:(NSError **)error +- (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)depth nativeLevels:(NSUInteger)nativeLevels error:(NSError **)error { if (![tree isKindOfClass:NSDictionary.class] || ![tree[attributesKey] isKindOfClass:NSDictionary.class] || @@ -59,7 +59,15 @@ - (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)de self.remainingNodes--; NSArray *children = tree[childrenKey]; NSNumber *childCount = tree[childCountKey]; - BOOL withheld = [childCount isKindOfClass:NSNumber.class] && childCount.unsignedIntegerValue > children.count; + BOOL knownChildCount = [childCount isKindOfClass:NSNumber.class] && childCount.doubleValue >= 0 && + 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 + userInfo:@{NSLocalizedDescriptionKey: @"snapshot boundary child count unavailable"}]; + return nil; + } + BOOL withheld = knownChildCount && childCount.unsignedIntegerValue > children.count; if (depth <= 1 || self.remainingNodes == 0) { self.truncated |= children.count > 0 || withheld; if (children.count == 0) return tree; @@ -76,6 +84,7 @@ - (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)de } NSDictionary *continuation = [self read:element depth:depth error:error]; 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 @@ -92,7 +101,7 @@ - (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)de if (!materialized) materialized = [[children subarrayWithRange:NSMakeRange(0, index)] mutableCopy]; break; } - NSDictionary *node = [self materialize:child depth:depth - 1 error:error]; + NSDictionary *node = [self materialize:child depth:depth - 1 nativeLevels:(nativeLevels > 0 ? nativeLevels - 1 : 0) error:error]; if (!node) return nil; if (node != child && !materialized) materialized = [[children subarrayWithRange:NSMakeRange(0, index)] mutableCopy]; [materialized addObject:node]; @@ -114,7 +123,7 @@ - (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)de capture.remainingNodes = maxNodes; capture.maximumNodes = maxNodes; NSDictionary *tree = [capture read:element depth:maxDepth + 1 error:error]; - NSDictionary *result = tree ? [capture materialize:tree depth:maxDepth + 1 error:error] : nil; + NSDictionary *result = tree ? [capture materialize:tree depth:maxDepth + 1 nativeLevels:capture.acceptedDepth error:error] : nil; *truncated = capture.truncated; return result; } 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 73e61876bf..098a60f2ff 100644 --- a/packages/platform-apple/src/snapshot-source/fixtures/foreground-owner.m +++ b/packages/platform-apple/src/snapshot-source/fixtures/foreground-owner.m @@ -85,10 +85,12 @@ - (id)userTestingSnapshotForElement:(id)element options:(NSDictionary *)options @"UIAccessibilitySnapshotKeyChildrenCount": @(i < 6 ? 1 : 0), @"UIAccessibilitySnapshotKeyChildren": tree ? @[tree] : @[]} mutableCopy]; } - if ([captureScenario isEqual:@"depth-missing-element"]) { + if ([@[@"depth-missing-element", @"depth-missing-count", @"depth-invalid-count", @"depth-fractional-count", @"depth-nan-count", @"depth-negative-count"] containsObject:captureScenario] || ([captureScenario isEqual:@"depth-continuation-count"] && level > 0)) { NSMutableDictionary *frontier = tree; while ([frontier[@"UIAccessibilitySnapshotKeyChildren"] count]) frontier = [frontier[@"UIAccessibilitySnapshotKeyChildren"] firstObject]; - [frontier removeObjectForKey:@"UIAccessibilitySnapshotKeyElement"]; + if ([captureScenario isEqual:@"depth-missing-element"]) [frontier removeObjectForKey:@"UIAccessibilitySnapshotKeyElement"]; + else if ([captureScenario isEqual:@"depth-missing-count"] || [captureScenario isEqual:@"depth-continuation-count"]) [frontier removeObjectForKey:@"UIAccessibilitySnapshotKeyChildrenCount"]; + 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-owner-change"] && level > 0) primaryApplication = replacementApplication; @@ -160,7 +162,7 @@ int main(int argc, const char *argv[]) if ([scenario hasPrefix:@"api-depth-"]) { require(levels == depth + 1, @"native levels must include the root exactly once"); NSDictionary *tree = nil; - for (NSUInteger i = 0; i < levels; i++) tree = @{@"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildren": tree ? @[tree] : @[]}; + for (NSUInteger i = 0; i < levels; i++) tree = @{@"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildrenCount": [scenario isEqual:@"api-depth-unknown"] ? [NSNull null] : @(tree ? 1 : 0), @"UIAccessibilitySnapshotKeyChildren": tree ? @[tree] : @[]}; return tree; } if ([element isEqual:@"root"]) return root; @@ -171,7 +173,7 @@ int main(int argc, const char *argv[]) else { NSUInteger count = 0; for (NSDictionary *node = result; node; node = [node[@"UIAccessibilitySnapshotKeyChildren"] firstObject]) count++; - require(count == depth + 1 && !truncated && requests == 1, @"every requested depth must include root plus permitted descendants"); + require(count == depth + 1 && truncated == [scenario isEqual:@"api-depth-unknown"] && requests == 1, @"every requested depth must include root plus permitted descendants"); } return 0; } @@ -191,9 +193,9 @@ 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 ([scenario isEqual:@"depth-missing-element"] || [scenario isEqual:@"depth-incomplete"]) { + 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]) { expectedCode = @"snapshot-tree-malformed"; - if ([scenario isEqual:@"depth-missing-element"]) expectedCaptures = 2; + if (![scenario isEqual:@"depth-incomplete"] && ![scenario isEqual:@"depth-continuation-count"]) expectedCaptures = 2; } if ([scenario isEqual:@"depth-owner-change"]) { replacementApplication = system; diff --git a/packages/platform-apple/src/snapshot-source/native-runtime.test.ts b/packages/platform-apple/src/snapshot-source/native-runtime.test.ts index 051297ac0f..21912f0449 100644 --- a/packages/platform-apple/src/snapshot-source/native-runtime.test.ts +++ b/packages/platform-apple/src/snapshot-source/native-runtime.test.ts @@ -41,6 +41,7 @@ describe.skipIf(process.platform !== 'darwin')('native snapshot capture', () => 'request-budget', 'runtime-budget', 'api-depth-0', + 'api-depth-unknown', 'api-depth-1', 'api-depth-4', 'api-depth-128', @@ -55,6 +56,12 @@ describe.skipIf(process.platform !== 'darwin')('native snapshot capture', () => 'depth-recovery', 'depth-wrapper', 'depth-missing-element', + 'depth-missing-count', + 'depth-invalid-count', + 'depth-fractional-count', + 'depth-nan-count', + 'depth-negative-count', + 'depth-continuation-count', 'depth-incomplete', 'depth-bound', 'depth-nodes',