Привет! Меня зовут Даниил, я iOS-инженер в Циане. В этой статье расскажу, как мы заставили XCUITest работать с экраном, к которому он в принципе не мог подойти.

У нас есть server-driven UI экран, наш BDUI, про движок которого была отдельная статья. Дерево вьюх у него настолько глубокое, что любое обращение теста к элементам падало. Причём двумя разными способами, и оба — не из-за кода, а из-за жёстких лимитов внутри самого фреймворка автоматизации.

В итоге мы научились читать дерево произвольной глубины и тапать/вводить текст в поля, которые лежат глубже ~60 уровней AX-дерева, где публичный XCUITest бессилен. Далее я буду фокусироваться на общей технике для XCUITest, а не на внутреннем устройстве нашего движка. Так что статья пригодится, даже если у вас никакого BDUI нет, а просто есть очень глубокий экран.

Финальные цифры реального прогона в качестве спойлера: собрано дерево maxDepth=143, nodeCount=145, три поля на глубине ~136 найдены, протапаны и заполнены.

Дальше я разберу цепочку неудачных попыток, ошибочные гипотезы и рабочее решение на приватных API. Важно: ниже указаны приватные API, живущие в тестовом бандле. В прод-код приложения это тащить нельзя. PoC выложен на GitHub.

Проблема: экран, к которому XCUITest не может подойти

Начнём с контекста. Server-driven UI устроен так: бэкенд присылает описание экрана в виде дерева виджетов, а наш движок вёрстки разворачивает это в нативную иерархию UIView. Каждый виджет — это, как правило, контейнер, внутри него ещё контейнер (стек, обёртка под отступы, обёртка под фон, обёртка под тап-таргет), и так далее. На выходе один «логический» контрол на экране легко превращается в 10–15 уровней нативной вложенности. А экран целиком — это уже сотни вложенных UIView, и глубина спокойно переваливает за сотню.

Для рендеринга и пользователя в этом нет проблем. Проблема появляется, когда за этот экран берётся XCUITest.

XCUITest не работает с UIView напрямую. Он работает со снапшотом дерева доступности (accessibility snapshot). Чтобы найти элемент, протапать его, ввести текст, XCUITest снимает снапшот иерархии, сериализует его и передаёт из процесса приложения в процесс теста через XPC.

В очень глубоком дереве этот механизм ломается сразу на двух уровнях. Симптомы таковы: тест, прекрасно работающий на любом другом экране, здесь падает на первой же строке, где мы трогаем UI. Причём сообщение об ошибке уводит в сторону: оно про «потерянное соединение с приложением», хотя приложение живо и здорово.

Раздвоение падения

Сообщение одно, а на самом деле ошибок две, с разными причинами и границами срабатывания.

Падение №1: снапшот слишком глубокого элемента

Если попросить снапшот у элемента, чьё поддерево глубже, чем позволяет фреймворк доступности, получаем:

Error kAXErrorIllegalArgument getting snapshot for element <...>

Это низкоуровневая ошибка из Accessibility-слоя: запрос снапшота на слишком глубоком элементе считается «нелегальным аргументом». Порог — где-то в районе 60 уровней: граница плавает из-за версии и формы дерева.

Падение №2: переполнение декодера NSXPC

Срабатывает ещё раньше первого: при поиске элемента по запросу вроде app.textFields["id"]. XCUITest снимает снапшот всего приложения, сериализует его и шлёт через XPC в процесс теста. У NSXPC-декодера есть жёсткий предохранитель на глубину вложенности коллекций. И на нашем дереве он срабатывает:

*** -[NSXPCDecoder _validateAllowedClass:forKey:allowingInvocations:]:
    decodeObjectForKey: too many nested collections

Снаружи это выглядит совершенно не так, как есть на самом деле:

Failed to get matching snapshots: Error ... Lost connection to the application (pid ...).

«Lost connection» звучит как краш приложения или таймаут. На деле приложение живёхонько — просто декодер на стороне теста отказался разворачивать слишком глубоко вложенный ответ и порвал XPC-канал. Если посмотреть на стек, видно характерную рекурсию распаковки снапшота:

0   ... -[NSXPCDecoder _validateAllowedClass:forKey:allowingInvocations:]
1   ... -[NSXPCDecoder decodeObjectForKey:]
2   ... -[XCElementSnapshot initWithCoder:]
3   ... -[NSXPCDecoder _decodeArrayOfObjectsForKey:]
4   ... -[XCElementSnapshot initWithCoder:]
5   ... -[NSXPCDecoder _decodeArrayOfObjectsForKey:]
6   ... -[XCElementSnapshot initWithCoder:]
7   ... -[NSXPCDecoder _decodeArrayOfObjectsForKey:]
        ... (повторяется на всю глубину дерева)

Каждая пара initWithCoder: / _decodeArrayOfObjectsForKey: — это один уровень дерева снапшота. Число этих пар соответствует числу уровней вложенности, и на нашем экране их слишком много. Оговорка: какое из двух падений вы увидите, зависит не только от глубины, но и от ширины дерева. В PoC экран — узкая цепочка из 145 узлов: он упирается в лимит снапшота раньше, чем успевает переполнить декодер, поэтому там срабатывает первое падение, а не второе. А на реальном BDUI-экране, где на каждом уровне сотни узлов, первым отрабатывает предохранитель NSXPC.

Держите это разделение в голове: kAXErrorIllegalArgument — про запрос снапшота у глубокого элемента, too many nested collections — про передачу большого снапшота через XPC при поиске элемента. Дальше мы будем обходить оба.

Как устроен поиск элемента в XCUITest

Почему app.textFields[id] фатален? Когда вы пишете app.textFields["my_field"].tap(), под капотом происходит примерно следующее. Запрос уходит в -[XCTElementQueryProcessor fetchMatchesForQuery:clientCapabilities:reply:]. Этот процессор, чтобы найти совпадения, снимает снапшот приложения целиком и возвращает его через reply-блок по XPC. То есть поиск одного текстового поля тянет за собой сериализацию всего дерева. На мелком экране — незаметно. На нашем — гарантированный too many nested collections.

Естественная мысль: «Ну наверняка есть способ попросить снапшот ограниченной глубины». Есть параметр maxDepth, и мы попробовали его навязать (об этом в следующем разделе). Эмпирический вывод: метод fetchMatchesForQuery не соблюдает maxDepth. Мы ограничивали глубину до 12 — XPC всё равно переполнялся. Поиск возвращает полное дерево независимо от заявленного лимита глубины.

Второй вариант: нельзя ли договориться о лимите вложенности через clientCapabilities, которые как раз передаются в fetchMatchesForQuery:clientCapabilities:reply:? Нет. В capability-словаре нет ничего про глубину:

  • XCTCapabilityImageFormat_* — формат картинок для скриншотов;

  • XCTCapabilityEvaluateQuery — умеет ли другая сторона соединения вычислять запросы;

  • idle-нотификации.

Ни одной ручки для ограничения глубины дерева. То есть штатного способа сказать «дай мне неполный снапшот, но не переполняй XPC» просто не существует. Вывод неприятный: любой подход через публичный поиск элемента на нашем дереве обречён. Значит, публичный поиск надо обходить целиком — и для чтения, и для взаимодействия.

Попытка №1: ограничиваем глубину снапшота

Первая идея, которая частично сработала. Раз запросы уходят без разумного лимита глубины, давайте принудительно проставим maxDepth в параметрах каждого снапшота. Параметры формируются в -[XCTElementQuery snapshotParameters] — свиззлим этот метод и подсовываем свой словарь:

guard let cls = NSClassFromString("XCTElementQuery"),
      let method = class_getInstanceMethod(cls, NSSelectorFromString("snapshotParameters"))
else { return false }

typealias DefaultsFn = @convention(c) (AnyObject, Selector) -> NSDictionary
let sel = NSSelectorFromString("snapshotParameters")
let original = unsafeBitCast(method_getImplementation(method), to: DefaultsFn.self)

let replacement: @convention(block) (AnyObject) -> NSDictionary = { obj in
    let params = NSMutableDictionary(dictionary: original(obj, sel))
    params["maxDepth"]      = 50    // держим каждый запрос легальным
    params["maxChildren"]   = 1_000 // и по ширине не даём взорваться
    params["maxArrayCount"] = 1_000
    return params
}
method_setImplementation(method, imp_implementationWithBlock(replacement))

Что это чинит: чтение. После установки ограничения print(app) и .debugDescription перестают падать с kAXErrorIllegalArgument — потому что теперь снапшот ограничен по глубине и не выходит за ~60 уровней. Это уже приятно: можно хотя бы посмотреть на верхушку дерева.

Что это не чинит: поиск элемента и взаимодействие. App.textFields["id"] как падал, так и падает. И element.snapshot() тоже: он идёт мимо этого хука — ограничение его не касается.

Почему падает? Потому что snapshotParameters и fetchMatchesForQuery — это два разных механизма. Ограничение глубины через snapshotParameters влияет на снапшоты, которые проходят через XCTElementQuery. А поиск элемента идёт через процессор запросов, который снимает полное дерево по-своему и maxDepth игнорирует (мы это проверили эмпирически). Так что это половина решения: оно открывает чтение верхнего уровня, но не даёт ни дотянуться до глубокого элемента, ни тем более протапать его.

Что из этого следует: ограничить глубину в одном месте недостаточно, потому что взаимодействие идёт другим механизмом. Нужен способ и читать дерево на любой глубине, и действовать мимо поиска элемента вообще.

Ремарка про legacy: приём не новый. WebDriverAgent (движок автоматизации, на котором построен Appium для iOS) свиззлит ровно -[XCAXClient_iOS defaultParameters], чтобы заменить дефолтный maxDepth на вменяемое значение. А он там INT_MAX, что слишком много для некоторых запросов — например, поиска внутри WKWebView. По опыту Appium разумный диапазон — 15–100, дальше запросы резко замедляются. Ключи параметров там ровно те же, что используем и мы: maxDepth / maxChildren / maxArrayCount / traverseFromParentsToChildren. Всё отличие — в том, что на нашем тулчейне (Xcode 26.2) классов XCAXClient_iOS / defaultParameters / sharedClient уже нет: проверено утилитами strings (печатает строковые литералы, зашитые в бинарь) и otool -ov (дампит Objective-C-метаданные: классы, методы, кодировки типов) по XCTAutomationSupport, XCTestCore, XCTest. Современный аналог точки хука — snapshotParameters на объекте запроса.

Как всё-таки прочитать дерево: запрос по частям и сшивание

Раз мы не можем за один запрос получить дерево глубже ~60, будем получать его по кускам и сшивать. Идея простая:

  1. Снимаем снапшот от корня с жёстким лимитом глубины (chunk = 50).

  2. Дерево обрежется на границе лимита. Каждый «обрезанный» лист — это на самом деле не лист, а точка, где нас остановил лимит.

  3. У такого листа есть его accessibilityElement. Берём его как новый корень и снимаем свежий ограниченный снапшот уже оттуда.

  4. Рекурсивно продолжаем, пока не упрёмся в настоящие листья.

Так мы обходим дерево по кускам вокруг лимита и собираем его на произвольную глубину.

Но снимать снапшот надо не публичным API — он либо переполнит XPC, либо снова упрётся в лимит. А нужно обращаться напрямую к служебному процессу автоматизации. Небольшое пояснение: XCUITest не разговаривает с приложением сам, между тестом и приложением есть фоновый процесс-посредник (демон testmanagerd), которому тест шлёт команды по XPC. Обычно это скрыто за публичным API, но к тому же каналу можно обратиться приватным методом напрямую. На Xcode 26.2 это XCTrequestSnapshotForElement:attributes:parameters:reply: у прокси сессии демона [[XCTRunnerDaemonSession sharedSession] daemonProxy]:.

Прямой запрос снапшота к демону (полный листинг)

let selector = NSSelectorFromString("_XCT_requestSnapshotForElement:attributes:parameters:reply:")
// proxy = [[XCTRunnerDaemonSession sharedSession] daemonProxy]
guard let proxy = snapshotProxy(for: selector),
      let method = class_getInstanceMethod(type(of: proxy), selector) else { return nil }

let sem = DispatchSemaphore(value: 0)
var result: AnyObject?

// reply: ^(id result, NSError *error) — на 26.2 приезжает XCElementSnapshot
let reply: @convention(block) (AnyObject?, AnyObject?) -> Void = { requestResult, error in
    defer { sem.signal() }
    if let error { NSLog("snapshot reply error: \(error)"); return }
    guard let requestResult else { return }
    // На 26.2 ответ приходит напрямую; если приедет обёртка — распакуем
    // -rootElementSnapshot, чтобы KVC-чтения легли на XCElementSnapshot.
    if requestResult.responds(to: NSSelectorFromString("rootElementSnapshot")) {
        result = requestResult.perform(NSSelectorFromString("rootElementSnapshot"))?.takeUnretainedValue()
    } else {
        result = requestResult
    }
}

typealias FunnelFn = @convention(c)
    (AnyObject, Selector, AnyObject, NSArray, NSDictionary, AnyObject) -> Void
let fn = unsafeBitCast(method_getImplementation(method), to: FunnelFn.self)
fn(proxy, selector, element, attributes as NSArray, parameters(), reply as AnyObject)

_ = sem.wait(timeout: .now() + 30)
return result

Обратите внимание на проверку перед распаковкой -rootElementSnapshot. Класс-обёртка XCUIElementSnapshotRequestResult в рантайме есть, но на 26.2 демон возвращает XCElementSnapshot напрямую — распаковывать нечего. На случай, если на другом тулчейне ответ приедет в обёртке, в коде оставлена проверка responds(to:).

private static func convert(_ snap: AnyObject, localDepth: Int) -> AXNode {
    let node = AXNode(snapshot: snap)
    let kids = (snap.value(forKey: "children") as? [AnyObject]) ?? []

    if kids.isEmpty, let axEl = node.axElement {
        // Любой лист МОЖЕТ быть границей обрезки — перезапрашиваем от него и смотрим.
        // Настоящий лист вернётся пустым (один лишний запрос) и мы остановимся;
        // обрезанный узел отдаст детей, и мы уходим глубже.
        if let deeper = requestSnapshot(for: axEl),
           let deeperKids = deeper.value(forKey: "children") as? [AnyObject],
           !deeperKids.isEmpty {
            node.wasReRooted = true
            node.children = deeperKids.map { convert($0, localDepth: 0) }
        }
    } else {
        node.children = kids.map { convert($0, localDepth: localDepth + 1) }
    }
    return node
}

Ключевая деталь, на которой легко обжечься. Здесь заманчива оптимизация: перезапрашивать не любой пустой лист, а только тот, что стоит на предсказанной границе обрезки — то есть где localDepth >= chunk - 1. Не делайте так. Мы проверили эмпирически: демон не соблюдает maxDepth точно. Реальная глубина обрезки плавает и не совпадает с заявленным лимитом. Если ограничивать перезапрос предсказанной глубиной, вы пропустите узлы, обрезанные «не там», и потеряете куски дерева.

Правильная и на удивление дешёвая стратегия — перезапрашивать от любого пустого листа. Да, для настоящих листьев это один лишний запрос, который вернётся пустым. Но зато вы гарантированно не пропустите ни одной границы обрезки, где бы она ни оказалась. Простота и корректность здесь важнее одного сэкономленного XPC-вызова.

Мы сшиваем в свою модель узла (AXNode), а не мутируем XCElementSnapshot — так безопаснее и нет завязки на внутреннее устройство снапшота:

final class AXNode {
    let elementType: Int
    let identifier: String
    let label: String
    let value: String?
    let frame: CGRect
    let axElement: AnyObject?   // именно он позволяет перезапросить поддерево глубже
    var children: [AXNode] = []

    init(snapshot s: AnyObject) {
        elementType = (s.value(forKey: "elementType") as? Int) ?? 0
        identifier  = (s.value(forKey: "identifier") as? String) ?? ""
        label       = (s.value(forKey: "label") as? String) ?? ""
        value       =  s.value(forKey: "value") as? String
        frame       = (s.value(forKey: "frame") as? CGRect) ?? .zero
        axElement   =  s.value(forKey: "accessibilityElement") as AnyObject?
    }
}

На этом проблема с чтением решена: мы умеем собрать дерево любой глубины, минуя оба лимита. Теперь — грабли, которые разбросаны по дороге.

Голый ключ "frame" игнорируется

Когда мы впервые собрали глубокое дерево, все узлы пришли с frame == .zero. А без фрейма нельзя посчитать, куда тапать.

Причина оказалась в списке запрашиваемых атрибутов. Запрос к демону берёт вторым аргументом массив атрибутов, которые нужно наполнить в снапшоте. Структурные атрибуты (identifier, elementType, children) заполняются всегда, независимо от того, что вы попросили. А вот frame и value заполняются, только если их запросить явно, и голый ключ "frame" демон игнорирует. Нужно запрашивать настоящие идентификаторы AX-атрибутов:

private static let attributes: [String] = [
    "identifier", "label", "value", "elementType", "children", "accessibilityElement",
    // frame надо просить по НАСТОЯЩЕМУ идентификатору атрибута — голый "frame"
    // демон игнорирует, и снапшот приходит с нулевым фреймом.
    "frame", "XC_kAXXCAttributeFrame", "XCTAccessibilityFrame", "XC_kAXXCAttributeCenterPoint",
    "XC_kAXXCAttributeValue", "XC_kAXXCAttributeLabel", "XC_kAXXCAttributeIdentifier",
    "XC_kAXXCAttributePlaceholderValue"
]

Ключ XC_kAXXCAttributeFrame — это и есть то, что реально наполняет фрейм, аналогично XC_kAXXCAttributeValue для value. Мы оставили в списке и голые ключи, и «настоящие» — лишние демон просто проигнорирует, а забыть нужный дороже. Вывод: если снапшот приходит с пустыми полями, которые «должны быть», первое подозрение — вы запрашиваете атрибут не под тем ключом.

Главное: взаимодействие за пределами лимита

Читать научились. Но нам нужно тапать и вводить текст в поля на глубине ~136. И вот тут все публичные способы отваливаются:

  • app.textFields["id"] — снапшотит приложение при поиске элемента → too many nested collections;

  • XCUICoordinate.tap() — тоже снапшотит приложение, чтобы разрешить координату относительно элемента → kAXErrorIllegalArgument;

  • XCUIApplication.typeText — снапшотит, чтобы понять фокус → тот же лимит.

Общая проблема: любой публичный жест сначала снапшотит приложение, и на глубоком дереве это фатально.

Решение — полностью отвязать взаимодействие от поиска элемента. Мы:

  1. Находим элемент прямым запросом к демону и получаем его экранный фрейм.

  2. Синтезируем события ввода напрямую в демон, минуя весь поиск и любые снапшоты.

Где тапать: объединение фреймов поддерева

Первая тонкость — координаты. Узел, несущий нужный identifier (в нашем случае это внешний контейнер составного текстового поля), часто имеет нулевой фрейм — реальный экранный прямоугольник несут его внутренние вьюхи. Поэтому целиться надо не во фрейм самого узла, а в объединение фреймов всего поддерева (union):

var unionFrame: CGRect {
    var rect: CGRect? = frame.isEmpty ? nil : frame
    for child in children {
        let childRect = child.unionFrame
        guard !childRect.isEmpty else { continue }
        rect = rect.map { $0.union(childRect) } ?? childRect
    }
    return rect ?? .zero
}

Центр этого объединённого прямоугольника — и есть точка тапа.

Синтез тапа

Строим XCPointerEventPath (touch), проигрываем нажатие/отпускание, заворачиваем в XCSynthesizedEventRecord и отправляем через XCTsynthesizeEvent:completion::

guard let pathCls = NSClassFromString("XCPointerEventPath") as? NSObject.Type else { return false }

// path = [[XCPointerEventPath alloc] initForTouchAtPoint:point offset:0]
guard let rawPath = pathCls.perform(NSSelectorFromString("alloc"))?.takeUnretainedValue() else { return false }
typealias InitTouch = @convention(c) (AnyObject, Selector, CGPoint, Double) -> AnyObject
let initSel = NSSelectorFromString("initForTouchAtPoint:offset:")
let initM   = class_getInstanceMethod(pathCls, initSel)!
let path    = unsafeBitCast(method_getImplementation(initM), to: InitTouch.self)(rawPath, initSel, point, 0)

callDouble(path, "pressDownAtOffset:", 0)
callDouble(path, "liftUpAtOffset:", 0.12)

// event = [[XCSynthesizedEventRecord alloc] initWithName:@"deep tap" interfaceOrientation:1]
//         + addPointerEventPath:path
let event = makeEventRecord(name: "deep tap", path: path)!
synthesize(event)   // _XCT_synthesizeEvent:completion:

Отправка синтезированного события в демон (полный листинг)

private static func synthesize(_ event: AnyObject) -> Bool {
    let selector = NSSelectorFromString("_XCT_synthesizeEvent:completion:")
    guard let proxy = snapshotProxy(for: selector),
          let method = class_getInstanceMethod(type(of: proxy), selector) else { return false }
    let sem = DispatchSemaphore(value: 0)
    var ok = true
    let completion: @convention(block) (AnyObject?) -> Void = { error in
        if let error { NSLog("synthesizeEvent error: \(error)"); ok = false }
        sem.signal()
    }
    typealias Fn = @convention(c) (AnyObject, Selector, AnyObject, AnyObject) -> Void
    unsafeBitCast(method_getImplementation(method), to: Fn.self)(proxy, selector, event, completion as AnyObject)
    _ = sem.wait(timeout: .now() + 15)
    return ok
}

Это конвейер синтеза событий ввода (HID): событие описано в абсолютных экранных координатах и не требует ни поиска элемента, ни снапшота. Поэтому оно проходит независимо от глубины дерева.

Синтез ввода текста

Ввод — тот же конвейер, но XCPointerEventPath строится через initForTextInput, а символы набиваются через typeText:atOffset:typingSpeed:shouldRedact::

let initSel = NSSelectorFromString("initForTextInput")
typealias InitFn = @convention(c) (AnyObject, Selector) -> AnyObject
let path = unsafeBitCast(method_getImplementation(initM), to: InitFn.self)(rawPath, initSel)

let typeSel = NSSelectorFromString("typeText:atOffset:typingSpeed:shouldRedact:")
typealias TypeFn = @convention(c)
    (AnyObject, Selector, NSString, Double, UInt, ObjCBool) -> Void
unsafeBitCast(method_getImplementation(typeM), to: TypeFn.self)(
    path, typeSel, text as NSString, /*atOffset*/ 0, /*typingSpeed*/ 30, /*shouldRedact*/ false
)

let event = makeEventRecord(name: "deep type", path: path)!
synthesize(event)

Пара замечаний. Во-первых, набранный текст попадёт в поле, у которого сейчас фокус, — поэтому сначала тапаем, потом печатаем. Во-вторых, соблазнительный XCTsendString:maximumFrequency:completion: тут не подойдёт: это селектор, доступный только на удалённой стороне (remote-only), в процессе теста его IMP отсутствует, работает только через конвейер synthesizeEvent.

Самые коварные грабли: на уровне ABI typingSpeed — целочисленный (Q), а не double

А почему падает assert, если скорость явно положительная? Изначально сигнатуру typeText:atOffset:typingSpeed:shouldRedact: мы «угадали» естественным образом: скорость печати — наверняка double (символов в секунду). Собрали, запустили — краш:

*** Assertion failure ... Invalid parameter not satisfying: keyEventsPerSecond > 0

Причём значение мы передавали явно положительное. Почему ассерт видит ноль? Objective-C описывает типы аргументов одной строкой, где каждая буква — отдельный тип. И реальная кодировка типов метода такова:

v44@0:8@16 d24 Q32 B40

Расшифровка по аргументам:

  • v —возвращаемый тип void,

  • @ — self),

  • : — selector,

  • @ — text, объект NSString),

  • datOffset — double,

  • QtypingSpeed — это код беззнакового целого, то есть NSUInteger,

  • BshouldRedact — BOOL.

То есть typingSpeed — это целое (Q), а вовсе не double. Если объявить его double в объявлении типа для @convention(c), аргумент уедет по соглашению о вызовах не в тот регистр. Целочисленные и float-аргументы на arm64 идут по разным наборам регистров. В целочисленный регистр, откуда метод читает keyEventsPerSecond, попадёт мусор/ноль — поэтому > 0 не выполняется, и ассерт валит тест. Симптом («скорость ноль») выглядит как логическая ошибка, а на деле это рассинхрон на уровне ABI.

Вывод практический и универсальный: не угадывайте сигнатуру приватного метода — сверяйте её по кодировке типов. Otool -ov по бинарю фреймворка отдаёт encoding для каждого селектора; из него однозначно видно, d там или Q. Для приватных API это не педантизм, а условие того, что код вообще запустится.

Бытовые грабли: клавиатура перекрывает поля

Когда всё уже работало для одного поля, тест начал падать на втором. Первое поле фокусируется — поднимается клавиатура — и она перекрывает нижнюю часть экрана. Второе поле, которое по вёрстке было ниже, оказывается под клавиатурой, и синтезированный тап попадает в клавиатуру, а не в поле.

Здесь ничего эзотерического, обычная жизнь UI-теста. В PoC мы решили это самым тупым надёжным способом — отрендерили целевые контролы в верхней части экрана (за счёт порядка детей в макете), так что клавиатура их не закрывает. В реальном тесте альтернатива — скроллить к полю перед тапом.

Показательный момент: после всей магии с приватными API и ABI тест в итоге упал на банальнейшей вещи. Держите в голове, что синтезированный тап координатный: он не «умный», он бьёт в точку, и если точку закрыла клавиатура — он бьёт в клавиатуру.

Что в результате

Реальный прогон PoC, окружение Xcode 26.2 (build 17C52), iPhone Simulator:

  • Собранное сшитое дерево: maxDepth = 143, nodeCount = 145.

  • Три текстовых поля на глубине ~136 уровней успешно найдены прямым запросом к демону.

  • Каждое протапано синтезированным тапом и заполнено синтезированным вводом: значения прочитаны обратно из снапшота (value = 1000000 и т. д.).

  • Тест зелёный.

То есть весь путь работает end-to-end: читаем дерево произвольной глубины, точно локализуем контрол, тапаем, вводим текст, верифицируем значение — всё это на дереве, к которому публичный XCUITest не может даже подойти.

Карта селекторов

Здесь перечислю всё, на что завязан PoC. Повторюсь: это приватные API, они привязаны к конкретной версии Xcode и могут сломаться при апгрейде.

Селектор / класс

Фреймворк

Роль

-[XCTElementQuery snapshotParameters]

XCTAutomationSupport

Точка свиззла для ограничения maxDepth (чинит чтение)

-[XCTElementQueryProcessor fetchMatchesForQuery:clientCapabilities:reply:]

XCTestCore

Публичный поиск элемента; снапшотит всё приложение → переполняет NSXPC (обходим)

[XCTRunnerDaemonSession sharedSession] → daemonProxy

XCTestCore

Прокси канала демона, через который идёт прямой запрос снапшота

XCTrequestSnapshotForElement:attributes:parameters:reply:

XCTestCore

Прямой запрос снапшота к демону (на 26.2 reply → XCElementSnapshot напрямую)

-[XCUIElementSnapshotRequestResult rootElementSnapshot]

XCTestCore

Распаковка обёртки, если ответ приедет в ней; на 26.2 не понадобилась

XCElementSnapshot (KVC: identifier/elementType/children/frame/value/accessibilityElement)

XCTestCore

Узел снапшота, из которого читаем данные

Ключи атрибутов: XC_kAXXCAttributeFrame, XC_kAXXCAttributeValue, XC_kAXXCAttributeCenterPoint, XC_kAXXCAttributeLabel, XC_kAXXCAttributeIdentifier, XC_kAXXCAttributePlaceholderValue

XCTestCore

Настоящие идентификаторы AX-атрибутов (иначе frame/value пустые)

XCPointerEventPath — initForTouchAtPoint:offset:, pressDownAtOffset:, liftUpAtOffset:

XCTestCore

Построение тач-жеста

XCPointerEventPath — initForTextInput, typeText:atOffset:typingSpeed:shouldRedact:

XCTestCore

Построение жеста ввода с клавиатуры (typingSpeed — Q!)

XCSynthesizedEventRecord — initWithName:interfaceOrientation:, addPointerEventPath:

XCTestCore

Обёртка события для отправки

XCTsynthesizeEvent:completion:

XCTestCore

Отправка синтезированного события в демон

XCTsendString:maximumFrequency:completion:

XCTestCore

Доступен только на удалённой стороне, в процессе теста IMP отсутствует (не используем)

Как перепроверять на своём тулчейне?

Нет гарантий, что это заработает в других версиями Xcode. Прежде чем полагаться на селектор, проверьте:

  • Lldb: image lookup -rn requestSnapshotForElement и любой другой селектор — есть ли он вообще и в каком классе.

  • Otool -ov по бинарю фреймворка — реальную кодировку типов метода. Именно так ловится история с typingSpeed = Q vs d.

  • Strings по фреймворкам — это быстрый способ проверить наличие символа/класса. Например, убедиться, что XCAXClient_iOS действительно отсутствует.

Отдельно отмечу: legacy-классы XCAXClient_iOS / defaultParameters, историческая точка хука для веб-драйверов iOS, на Xcode 26.2 отсутствуют — проверено strings и otool -ov. Если вы переносите старый рецепт, первым делом убедитесь, что классы, на которые он рассчитан, вообще есть.

Когда это оправдано и дисклеймер

Скажу честно: это тяжёлая артиллерия, и в большинстве случаев она не нужна. И снова повторю: приватные API нельзя тащить в прод-код приложения. Здесь всё живёт исключительно в тестовом бандле и дополнительно вырезается из RELEASE-сборки (#if !RELEASE). App Review, стабильность, поддержка — всё против приватных символов в продакшене.

Лучше не делать деревья такими глубокими. Если у вас server-driven UI, у которого каждый логический контрол оборачивается в десяток контейнеров, — это, в первую очередь, повод поговорить с командой движка вёрстки о схлопывании лишних уровней. Приватные API лечат симптом, а не причину.

Даже в тестах это решение хрупко: всё завязано на конкретный Xcode. Предусмотрите запасной путь: определяйте отсутствие класса/селектора и корректно пропускайте тест, а не роняйте всю сюиту. В PoC installClamp() возвращает false, если точка хука не найдена.

Если же у вас есть критичный сценарий на экране, к которому публичный XCUITest физически не может подойти, теперь вы знаете, что это в принципе решаемо. План таков: ограничение глубины для чтения верхушки, прямой запрос к демону и сшивание для чтения на любой глубине, синтезированные события ввода для взаимодействия мимо поиска элемента. И при этом вы знаете, где вас будут ждать грабли.

Исходники со всеми селекторами, сшивателем и синтезом событий собрали в PoC на GitHub. Буду рад вопросам и вашему опыту в комментариях — если сталкивались с похожими лимитами XCUITest, расскажите, как выкручивались. Спасибо за внимание!