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/.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/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..70c9d6530f --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextInputProbe.swift @@ -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) + } +} 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..fd1a0d51c9 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextInputProbeTests.swift @@ -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 diff --git a/apple/snapshot-bridge/README.md b/apple/snapshot-bridge/README.md index d3769abb46..fb6852eb80 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,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. 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..857b8bc453 --- /dev/null +++ b/apple/snapshot-bridge/SnapshotBridgeCapture.m @@ -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; +} diff --git a/apple/snapshot-bridge/SnapshotBridgeRuntime.m b/apple/snapshot-bridge/SnapshotBridgeRuntime.m index 9579dfa554..2d27591d55 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); @@ -328,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; @@ -362,7 +383,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-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() }, diff --git a/packages/platform-apple/src/snapshot-source/adapter.test.ts b/packages/platform-apple/src/snapshot-source/adapter.test.ts index e3b1278af8..7e5de38c62 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,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: [] }] + : [], }, }, { 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..098a60f2ff 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 @@ -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 @@ - (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"}]; + 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"); } 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..21912f0449 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,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); + }); }); 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([ 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/__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/, + ); +}); 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) {