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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## Unreleased

- Added (ios): `type` and `fill` work in the Apple Pay sheet on iOS Simulator instead of failing
with `TEXT_INPUT_NOT_FOCUSED`. `com.apple.PassbookUIService` is served in place like the web
sign-in host (#2438).
- Changed (ios): Simulator captures also probe for `com.apple.PassbookUIService`. It can keep
running after the Apple Pay sheet closes, so a later capture in that app can report
`system-surface-host-lingering` while the process stays alive.
- Changed (ios): the in-place system surface disclosure now names the sheet kind. The web sign-in
sentence changed from "A system web sign-in sheet is presented over the app, so this snapshot
shows that sheet (hosted out of the app process)." to "This snapshot shows a system web sign-in
sheet presented over the app (hosted out of the app process), not app content". The payment host
says "the system Apple Pay sheet" instead.
- Fixed (android): a chunked `record stop` (recordings over 170 s) no longer warns that screenrecord
stopped before record stop at the 180 s limit. Rotation always ends every earlier chunk before
stop, so the warning now fires only when the last chunk's recorder had already exited.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import Foundation

// iOS out-of-process system surfaces observed and driven IN PLACE, never activated: activating such
// a host cancels what it presents (issue #2438; rationale in docs/adr/0004). Membership is the
// golden fixture contracts/fixtures/ios-system-surface-hosts.json, mirrored by the TS twin
// packages/contracts/src/ios-system-surface.ts; drift fails on either side without a simulator.
// a host cancels what it presents (issue #2438; rationale in docs/adr/0004). Membership and each
// host's rationale live in the golden fixture contracts/fixtures/ios-system-surface-hosts.json,
// mirrored by the TS twin packages/contracts/src/ios-system-surface.ts; drift fails on either side
// without a simulator.
enum SystemSurfaceHostKind: String {
case webAuth = "web-auth"
case payment = "payment"
}

struct SystemSurfaceHost: Equatable {
Expand All @@ -15,7 +17,8 @@ struct SystemSurfaceHost: Equatable {

enum SystemSurfaceHostRegistry {
static let hosts: [SystemSurfaceHost] = [
SystemSurfaceHost(bundleId: "com.apple.SafariViewService", kind: .webAuth)
SystemSurfaceHost(bundleId: "com.apple.SafariViewService", kind: .webAuth),
SystemSurfaceHost(bundleId: "com.apple.PassbookUIService", kind: .payment),
]

static func host(forBundleId bundleId: String?) -> SystemSurfaceHost? {
Expand Down Expand Up @@ -61,12 +64,17 @@ extension RunnerTests {

func testSystemSurfaceHostRegistryRecognizesRegisteredHosts() {
XCTAssertTrue(SystemSurfaceHostRegistry.isSystemSurfaceHost("com.apple.SafariViewService"))
XCTAssertTrue(SystemSurfaceHostRegistry.isSystemSurfaceHost("com.apple.PassbookUIService"))
XCTAssertFalse(SystemSurfaceHostRegistry.isSystemSurfaceHost("com.example.app"))
XCTAssertFalse(SystemSurfaceHostRegistry.isSystemSurfaceHost(nil))
XCTAssertEqual(
SystemSurfaceHostRegistry.host(forBundleId: "com.apple.SafariViewService")?.kind,
.webAuth
)
XCTAssertEqual(
SystemSurfaceHostRegistry.host(forBundleId: "com.apple.PassbookUIService")?.kind,
.payment
)
}
}
#endif
10 changes: 8 additions & 2 deletions contracts/fixtures/ios-system-surface-hosts.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
{
"description": "iOS out-of-process system surfaces that agent-device observes and drives IN PLACE, never by activation. Activating or relaunching such a host destroys what it presents: com.apple.SafariViewService hosts ASWebAuthenticationSession / SFSafariViewController, and any XCUIApplication.activate() or simctl launch cancels the auth session (issue #2438). Source of truth shared by the TypeScript registry (packages/contracts/src/ios-system-surface.ts) and the Swift runner registry (RunnerSystemSurfaceHostPolicy.swift); a change here must keep both parity tests green. `processExecutable` is the simulator app-binary path fragment the TypeScript host-side presence probe matches with `pgrep -f`, confirming device scope from the matched process's environment; the Swift runner detects the host by bundle id via XCUIApplication.state and ignores it.",
"description": "iOS out-of-process system surfaces that agent-device observes and drives IN PLACE, never by activation: activating or relaunching such a host destroys what it presents (issue #2438; rationale in docs/adr/0004). Each host's `note` says what it hosts and why it is registered. Source of truth shared by the TypeScript registry (packages/contracts/src/ios-system-surface.ts) and the Swift runner registry (RunnerSystemSurfaceHostPolicy.swift); a change here must keep both parity tests green. `processExecutable` is the simulator app-binary path fragment the TypeScript host-side presence probe matches with `pgrep -f`, confirming device scope from the matched process's environment; the Swift runner detects the host by bundle id via XCUIApplication.state and ignores it.",
"hosts": [
{
"bundleId": "com.apple.SafariViewService",
"kind": "web-auth",
"processExecutable": "SafariViewService.app/SafariViewService",
"note": "Hosts ASWebAuthenticationSession and SFSafariViewController out of the app's process. Presented over a still-foreground app; read and driven in place via the XCTest runner (the host AX bridge cannot see it: the app remains the AX primaryApp)."
"note": "Hosts ASWebAuthenticationSession and SFSafariViewController out of the app's process; any XCUIApplication.activate() or simctl launch cancels the auth session. Presented over a still-foreground app; read and driven in place via the XCTest runner (the host AX bridge cannot see it: the app remains the AX primaryApp)."
},
{
"bundleId": "com.apple.PassbookUIService",
"kind": "payment",
"processExecutable": "PassbookUIService.app/PassbookUIService",
"note": "Hosts the Apple Pay sheet (PKPaymentAuthorizationController) and its billing/shipping/contact forms out of the app's process. Those text fields never appear in the app's tree, so bare `type` must address this host to reach the first responder."
}
]
}
20 changes: 13 additions & 7 deletions docs/adr/0004-ios-snapshot-backend-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,10 +358,16 @@ predicate sound. This also makes issue #2438's second bug (a stale tree served c
teardown) unrepresentable for the delegated-auth flow, because the session never binds to the host.

Captures of a system surface carry a response-level `systemSurface` provenance and the shared
`IOS_SYSTEM_SURFACE_DISCLOSURE`, so the agent is told the controls belong to a system sheet rather
than the app. They are also lineaged to the host rather than the app, so their comparison identity
differs from an app capture's by construction: every consumer that asks "are these two captures the
same presentation" refuses a cross-surface pair through ordinary key equality, and no comparison
site carries a surface check of its own. Physical devices always use the runner, so the in-place
serve applies there without a route change; the Simulator route probe is the only
Simulator-specific piece.
`iosSystemSurfaceDisclosure`, worded per host kind, so the agent is told the controls belong to a
system sheet (web sign-in, Apple Pay) rather than the app. They are also lineaged to the host rather
than the app, so their comparison identity differs from an app capture's by construction: every
consumer that asks "are these two captures the same presentation" refuses a cross-surface pair
through ordinary key equality, and no comparison site carries a surface check of its own. Physical
devices always use the runner, so the in-place serve applies there without a route change; the
Simulator route probe is the only Simulator-specific piece.

The Apple Pay host (`com.apple.PassbookUIService`) joined the registry for text entry as much as for
snapshots. Its billing, shipping, and contact forms hold text fields the session app's tree cannot
resolve, and a bare `type` addressed to the app process never sees that keyboard. Addressing the
host in place is what lets the runner's first-responder route type into them; no text-entry branch
changed for it.
14 changes: 10 additions & 4 deletions examples/test-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ It is intentionally small, but each surface is dense with durable accessibility
- `Product detail`: back navigation, quantity stepper, multiline notes, save action
- `Checkout form`: required-field validation, fill vs type, checkbox state, choice groups, keyboard dismiss, success summary
- `Settings`: switch rows, accordion content, loading and error states, retry flow, destructive-confirm modal
- `Automation lab`: long-press, alert-result, app-event, app-state, appearance, orientation, permission-recovery, and log canaries
- `Automation lab`: long-press, alert-result, app-event, app-state, appearance, orientation, permission-recovery, log canaries, a flattened (`accessible={true}`) text input, and an Apple Pay sheet hosted in `com.apple.PassbookUIService`
- `WebView accessibility`: a deterministic semantic fixture plus live websites with varied HTML for native accessibility snapshot verification

Navigation uses Expo Router native bottom tabs, so the tab bar itself is also part of the test surface.
Expand All @@ -38,6 +38,7 @@ These are the main case families this app can support without adding more screen
- `press` on stable buttons, pills, and rows
- `fill` on single-line and multiline fields
- `type` after focus for append flows
- `type` into a focused field the accessibility tree cannot resolve (flattened input, Apple Pay billing address form)
- `get text` on headings, badges, summaries, and accordion content
- `is visible`, `is exists`, and `is absent` assertions
- `wait` for async loading and success states
Expand Down Expand Up @@ -90,9 +91,14 @@ The `/automation` route is intentionally JavaScript-only and can be opened from
outcomes for long press, native alert actions, app-event name/payload, app state, appearance,
window orientation, and microphone permission recovery; the
`maestro-clickable-first-target` duplicate pair exercises Android Maestro clickable-first
ordering. CI repacks JavaScript-only changes into the
cached Release app without starting Metro; native configuration changes intentionally produce one
new fingerprinted build that all simulator consumers share.
ordering. `automation-flattened-group` wraps a `TextInput` in an `accessible={true}` view, so the
field itself never appears in the accessibility tree and only the keyboard proves it has focus;
`automation-flattened-value` mirrors what was typed. `automation-open-apple-pay` (iOS only, native
module `modules/apple-pay-lab`) presents the system Apple Pay sheet requiring a billing address plus
contact email and phone; those forms are hosted out of process in `com.apple.PassbookUIService`, and
`automation-apple-pay-result` reports `authorized` or `dismissed` once the sheet closes. CI repacks
JavaScript-only changes into the cached Release app without starting Metro; native configuration
changes intentionally produce one new fingerprinted build that all simulator consumers share.

### iOS simulator

Expand Down
5 changes: 5 additions & 0 deletions examples/test-app/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ module.exports = {
supportsTablet: true,
bundleIdentifier: 'com.callstack.agentdevicelab',
infoPlist: accessoryInfoPlist,
// Lets the simulator present the Apple Pay sheet (modules/apple-pay-lab). The merchant id
// is a fixture, not a registered merchant.
entitlements: {
'com.apple.developer.in-app-payments': ['merchant.com.callstack.agentdevicelab'],
},
},
android: {
package: 'com.callstack.agentdevicelab',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"platforms": ["ios"],
"apple": {
"modules": ["ApplePayLabModule"]
}
}
16 changes: 16 additions & 0 deletions examples/test-app/modules/apple-pay-lab/ios/ApplePayLab.podspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
Pod::Spec.new do |s|
s.name = 'ApplePayLab'
s.version = '1.0.0'
s.summary = 'Apple Pay sheet fixture for Agent Device Tester'
s.description = s.summary
s.license = { :type => 'MIT' }
s.author = { 'Callstack' => 'opensource@callstack.com' }
s.homepage = 'https://github.com/callstack/agent-device'
s.platforms = { :ios => '15.1' }
s.source = { :git => 'https://github.com/callstack/agent-device.git' }
s.static_framework = true

s.dependency 'ExpoModulesCore'
s.frameworks = 'PassKit', 'UIKit'
s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}'
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import ExpoModulesCore
import PassKit

// Presents the system Apple Pay sheet, which iOS hosts out of process in
// com.apple.PassbookUIService. The billing address and contact forms inside that sheet are the
// fixture for typing into a focused field that the app's own accessibility tree cannot resolve.
public final class ApplePayLabModule: Module {
public func definition() -> ModuleDefinition {
Name("ApplePayLab")

Function("canMakePayments") { () -> Bool in
PKPaymentAuthorizationController.canMakePayments()
}

AsyncFunction("presentPaymentSheetAsync") { (promise: Promise) in
ApplePayLabController.shared.present(promise: promise)
}.runOnQueue(.main)
}
}

private final class ApplePayLabController: NSObject, PKPaymentAuthorizationControllerDelegate {
static let shared = ApplePayLabController()

// Must match com.apple.developer.in-app-payments in app.config.js. The simulator accepts any
// merchant id declared there and offers its built-in test cards.
private static let merchantIdentifier = "merchant.com.callstack.agentdevicelab"

// One presented sheet at a time. Holding the controller keeps it alive until PassKit reports
// that it finished; the promise resolves with the outcome then.
private struct Session {
let controller: PKPaymentAuthorizationController
let promise: Promise
var authorized = false
}

private var session: Session?

func present(promise: Promise) {
guard session == nil else {
promise.reject(
Exception(
name: "PaymentSheetAlreadyPresented",
description: "The Apple Pay sheet is already presented."
)
)
return
}

let request = PKPaymentRequest()
request.merchantIdentifier = Self.merchantIdentifier
request.countryCode = "US"
request.currencyCode = "USD"
request.supportedNetworks = [.visa, .masterCard, .amex]
request.merchantCapabilities = .threeDSecure
request.requiredBillingContactFields = [.postalAddress]
request.requiredShippingContactFields = [.emailAddress, .phoneNumber]
request.paymentSummaryItems = [
PKPaymentSummaryItem(label: "Agent Device Tester", amount: NSDecimalNumber(string: "1.00")),
]

let controller = PKPaymentAuthorizationController(paymentRequest: request)
controller.delegate = self
session = Session(controller: controller, promise: promise)
controller.present { presented in
guard !presented else { return }
self.session = nil
promise.reject(
Exception(
name: "PaymentSheetNotPresented",
description: "iOS refused to present the Apple Pay sheet."
)
)
}
}

func paymentAuthorizationController(
_ controller: PKPaymentAuthorizationController,
didAuthorizePayment payment: PKPayment,
handler completion: @escaping (PKPaymentAuthorizationResult) -> Void
) {
session?.authorized = true
completion(PKPaymentAuthorizationResult(status: .success, errors: nil))
}

func paymentAuthorizationControllerDidFinish(_ controller: PKPaymentAuthorizationController) {
controller.dismiss {
guard let session = self.session else { return }
self.session = nil
session.promise.resolve(session.authorized ? "authorized" : "dismissed")
}
}
}
9 changes: 5 additions & 4 deletions examples/test-app/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions examples/test-app/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,8 @@ overrides:
js-yaml@4: ^4.3.1
nanoid@3: ^3.3.18
'@babel/core@7': ^7.29.6
# expo-modules-core pins ~56.0.10; 56.0.13 is the first patch whose Swift compiles under Xcode 27
# (JavaScriptRuntime.swift formed a C function pointer from a ternary).
expo-modules-jsi: 56.0.13
patchedDependencies:
image-size@1.2.1: patches/image-size@1.2.1.patch
Loading
Loading