perf(ios): speed up deep snapshots and keep first taps reliable (#2414)

* perf(ios): recover deep snapshots and isolate optional tap probes

* chore(gates): enforce snapshot assets and optional probe lifecycle

* fix(ios): preserve capture bounds and local probe recovery

* chore(gates): validate base package assets with its own policy

* chore(gates): verify recovery failures respect launch observation policy

* fix(ios): fail closed on unknown snapshot frontier completeness
This commit is contained in:
Michał Pierzchała
2026-09-09 18:24:15 +02:00
committed by GitHub
parent 4d7d9be21e
commit 0dfd65f6a2
26 changed files with 743 additions and 68 deletions
+15 -1
View File
@@ -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: |
+1
View File
@@ -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
@@ -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
@@ -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)
@@ -0,0 +1,101 @@
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:
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)
}
}
@@ -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()
@@ -0,0 +1,177 @@
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))
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
}
#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS)
private final class ProbeThreadResult: @unchecked Sendable {
var contained = false
}
#endif
+17 -1
View File
@@ -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,19 @@ 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.
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. Budget exhaustion and malformed continuations
use non-launch failure codes, so the route falls back without launch re-polling.
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.
@@ -0,0 +1,11 @@
#import <Foundation/Foundation.h>
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
@@ -0,0 +1,129 @@
#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 nativeLevels:(NSUInteger)nativeLevels error:(NSError **)error;
@end
@implementation SnapshotTreeCapture
- (nullable NSDictionary *)read:(id)element depth:(NSUInteger)depth error:(NSError **)error
{
NSUInteger attemptDepth = MIN(depth, self.acceptedDepth);
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"}];
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 || retries >= 2) {
if (error) *error = failure;
return nil;
}
attemptDepth = MAX(1, attemptDepth / 2);
self.acceptedDepth = attemptDepth;
}
}
- (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)depth nativeLevels:(NSUInteger)nativeLevels 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 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;
NSMutableDictionary *bounded = [tree mutableCopy];
bounded[childrenKey] = @[];
return bounded;
}
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;
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
userInfo:@{NSLocalizedDescriptionKey: @"snapshot continuation children unavailable"}];
return nil;
}
}
if (withheld && children.count < childCount.unsignedIntegerValue) self.truncated = YES;
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 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];
index++;
}
if (!materialized && children == tree[childrenKey]) return tree;
NSMutableDictionary *result = [tree mutableCopy];
result[childrenKey] = materialized ?: children;
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 + 1;
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 nativeLevels:capture.acceptedDepth error:error] : nil;
*truncated = capture.truncated;
return result;
}
+24 -3
View File
@@ -5,6 +5,7 @@
*/
#import "SnapshotBridgeRuntime.h"
#import "SnapshotBridgeCapture.h"
#import <CoreGraphics/CoreGraphics.h>
#import <objc/message.h>
@@ -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 @@ static void finishRequestWatchdog(dispatch_source_t watchdog, SnapshotWatchdogSt
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);
@@ -328,6 +342,13 @@ static void finishRequestWatchdog(dispatch_source_t watchdog, SnapshotWatchdogSt
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;
@@ -362,7 +383,7 @@ static void finishRequestWatchdog(dispatch_source_t watchdog, SnapshotWatchdogSt
@"ok" : @YES,
@"pid" : @(pid),
@"tree" : tree,
@"truncated" : @(truncated),
@"truncated" : @((BOOL)(truncated || acquisitionTruncated)),
@"automationEnabled" : @(automationEnabled),
};
}
@@ -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.
@@ -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(
@@ -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() },
@@ -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,12 +244,15 @@ 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',
XC_kAXXCAttributeFrame: { X: 0, Y: 0, Width: 390, Height: 844 },
XC_kAXXCAttributeChildren: [],
XC_kAXXCAttributeChildren:
request.maxDepth === 1
? [{ XC_kAXXCAttributeElementType: 'Button', XC_kAXXCAttributeChildren: [] }]
: [],
},
},
{
@@ -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(
@@ -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<void>((resolve) => {
@@ -1,4 +1,5 @@
#import "SnapshotBridgeRuntime.h"
#import "SnapshotBridgeCapture.h"
#import <CoreGraphics/CoreGraphics.h>
#import <dlfcn.h>
@@ -6,6 +7,7 @@
static id primaryApplication;
static id replacementApplication;
static NSUInteger captureCount;
static NSString *captureScenario;
@interface AXElement : NSObject
@property(nonatomic) pid_t pid;
@@ -36,6 +38,64 @@ static NSUInteger captureCount;
- (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"}];
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 ([@[@"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];
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;
return tree;
}
if (replacementApplication) primaryApplication = replacementApplication;
return @{ @"UIAccessibilitySnapshotKeyAttributes": @{ @2: @"fixture app" },
@"UIAccessibilitySnapshotKeyChildren": @[] };
@@ -85,6 +145,39 @@ 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": @{}, @"UIAccessibilitySnapshotKeyChildrenCount": [scenario isEqual:@"api-depth-unknown"] ? [NSNull null] : @(tree ? 1 : 0), @"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 == [scenario isEqual:@"api-depth-unknown"] && requests == 1, @"every requested depth must include root plus permitted descendants");
}
return 0;
}
captureScenario = scenario;
AXElement *target = [AXElement new];
target.pid = 42;
AXElement *system = [AXElement new];
@@ -93,7 +186,25 @@ int main(int argc, const char *argv[])
primaryApplication = target;
NSString *expectedCode = nil;
NSUInteger expectedCaptures = 0;
if ([scenario isEqualToString:@"stable"]) {
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"] ? 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]) {
expectedCode = @"snapshot-tree-malformed";
if (![scenario isEqual:@"depth-incomplete"] && ![scenario isEqual:@"depth-continuation-count"]) expectedCaptures = 2;
}
if ([scenario isEqual:@"depth-owner-change"]) {
replacementApplication = system;
expectedCode = @"foreground-owner-changed";
}
} 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;
} else if ([scenario isEqualToString:@"changed"]) {
replacementApplication = system;
@@ -110,16 +221,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 : [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:@"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 {
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"] ? 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];
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");
}
@@ -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",
@@ -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,40 @@ 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',
'identity',
'request-budget',
'runtime-budget',
'api-depth-0',
'api-depth-unknown',
'api-depth-1',
'api-depth-4',
'api-depth-128',
'rejected',
'wide-nodes',
'wide-continuation',
'zero-depth',
'covered',
'changed',
'missing',
'malformed',
'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',
'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);
});
});
@@ -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,
@@ -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([
@@ -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 },
@@ -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/,
);
});
@@ -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/);
+2
View File
@@ -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) {