Разработка
Reflex: архитектура для создания мобильных приложений с использованием агентов
Когда мы решили заново построить наши приложения нативно, мы вернулись к исходной точке и задали простой вопрос: как должно быть устроено мобильное приложение, если большую часть кода пишут агенты?
В статье «Нативная разработка снова стала будущим мобильных приложений в Shopify» мы объяснили, почему возвращаемся к Swift и Kotlin, а в материале про Helix рассказали, как агенты заново собирают экраны через небольшие контрольные точки и строгие проверки. В обеих статьях упоминается интерфейс командной строки, который позволяет агенту проверять состояние приложения и выполнять действия без симулятора. Эта статья посвящена архитектуре, которая стоит за этим инструментом. Мы называем её Reflex.
Когда мы решили заново построить наши приложения нативно, мы вернулись к исходной точке и задали простой вопрос: как должно быть устроено мобильное приложение, если большую часть кода пишут агенты? Самым большим ограничением оказался цикл обратной связи. Агент может написать экран за несколько минут, но чтобы доказать, что экран действительно работает, нужны эмулятор, сборка, сценарий нажатий и много терпения. Мы уже вложились в инструменты для симуляторов, чтобы ускорить этот процесс, но в какой-то момент упёрлись в предел того, насколько быстро всё это вообще может работать.
Так появился Reflex. Логика приложения работает без интерфейса, каждый экран открывает своё состояние и доступные действия через небольшой единый контракт, а интерфейс командной строки управляет этим контрактом на обеих платформах из одного и того же файла теста. Это позволило нам получить чрезвычайно быстрые циклы обратной связи и одновременно держать две нативные реализации синхронизированными. Ниже мы по частям разберём, как всё это работает и что потребуется, чтобы построить нечто подобное для собственного приложения.
В этой статье:
- Обзор Reflex — четыре основных принципа;
- Общий тест, а не общий код — почему один файл теста используется для двух нативных приложений;
- Контракт, построенный с нуля — разделение на модули, ViewModel, Introspection Kit и дерево узлов;
- Наша «тестовая чаша» — что и где мы тестируем, с примером пользовательского сценария;
- А как насчёт открытого исходного кода? — что понадобится, чтобы реализовать это самостоятельно.
Обзор Reflex
Reflex — это архитектура на основе MVVM, которая организует приложение так, чтобы агент мог быстро его создавать, тестировать и управлять им, вообще не отрисовывая интерфейс. Она строится на четырёх принципах:
- Основной модуль без интерфейса. Всё, кроме представлений, находится в модуле без импортов UI и без кода, зависящего от устройства. Благодаря этому логика приложения может работать без интерфейса на macOS или JVM.
- Строгий контракт ViewModel. ViewModel каждого экрана имеет одну и ту же структуру в стиле Elm: действия поступают на вход, состояние выходит наружу, а все изменения состояния происходят в одном месте.
- Интроспекция ViewModel. Каждая ViewModel представлена через JSON-контракт: имя, состояние и набор принимаемых действий. Именно это позволяет процессу вне приложения, например интерфейсу командной строки, прочитать состояние экрана и выполнить действие.
- Интерфейс командной строки. Независимый от платформы инструмент командной строки, который позволяет агентам взаимодействовать с приложением через этот контракт, не запуская симулятор или эмулятор. Поскольку инструмент не зависит от платформы, мы можем написать один тестовый сценарий, работающий на обеих платформах.
Вместе эти четыре принципа дают агенту возможность самостоятельно исследовать приложение функцию за функцией с чрезвычайно быстрым циклом обратной связи и без зависимости от устройства. Поскольку один и тот же файл теста выполняется для iOS и Android, функция не может выйти на одной платформе, если вторая от неё отстаёт.
С точки зрения агента всё выглядит так:
lsвыводит название текущего экрана, его состояние в JSON и доступные действия.do Action [json]вызывает одно из этих действий, точно моделируя взаимодействие пользователя.- Интерфейс командной строки может работать как с основным процессом без интерфейса, так и с реальной сборкой приложения, но в обоих случаях он взаимодействует только с ViewModel и никогда — с отрисованным интерфейсом.
$ dev cli ios --fixtures
> ls
InventoryDetail
state {"productId": "1", "stockCount": 12, "isSaved": false}
actions Increment, Decrement, Save, SetQuantity {quantity}, goBack
> do Increment
> ls
InventoryDetail
state {"productId": "1", "stockCount": 13, "isSaved": false}
actions Increment, Decrement, Save, SetQuantity {quantity}, goBack
Вот и весь пользовательский интерфейс. Никакого TUI, цветов или спиннеров. Агентам всё это безразлично, а время, которое мы не потратили на украшение терминала, ушло на улучшение самого цикла обратной связи.
Общий тест, а не общий код
Два нативных приложения означают две реализации каждого экрана, и что-то должно поддерживать их эквивалентность. Наш ответ превратился в принцип, к которому мы постоянно возвращаемся: делить между платформами нужно тест, а не код. Код пишется дважды, тесты — один раз. Этот подход основан на двух решениях.
- Никакого общего кода между платформами. Swift на iOS, Kotlin на Android, каждая платформа использует собственный набор UI-инструментов и свою модель конкурентности, без промежуточного кроссплатформенного слоя. Когда большую часть кода пишут агенты, вторая нативная реализация стоит значительно дешевле, чем раньше. А абстракционный слой по-прежнему имеет полную стоимость: он добавляет поверхность для сопровождения, скрывает особенности платформы, которые позже понадобятся для оптимизации производительности, и превращает каждое обновление платформы в двухэтапную миграцию. Написать одну и ту же ViewModel дважды дешевле, чем поддерживать мост между двумя платформами.
- Паритет обеспечивается тестами, а не кодом. Если код не общий, что-то другое должно удерживать платформы в синхронном состоянии. Для нас этим чем-то стал набор тестов: каждый тест поведения выполняется на обеих платформах, и pull request нельзя объединить, если он проходит только на одной из них. Общим артефактом является тест.
Следующий вопрос — какие именно тесты использовать. У каждого привычного варианта есть проблема.
- Кроссплатформенные сквозные тесты. Один тест покрывает оба приложения, и мы по-прежнему используем такие тесты для нескольких конкретных сценариев. Нам удалось добиться высокой стабильности собственного сквозного фреймворка, но такие тесты всё равно недостаточно быстры для агентных циклов и CI-проверок. Им также нужен симулятор или эмулятор, а для разбора ошибки приходится изучать видео или снимки экрана.
- Нативные UI-тесты с XCUITest и Espresso. Они быстрее и надёжнее, но всё равно привязаны к симулятору или эмулятору и пишутся отдельно для каждой платформы, поэтому каждый тест приходится дублировать, а успешное выполнение на одной платформе ничего не говорит о второй.
- Модульные тесты. Они быстрые, но тестируют имитацию приложения, а не само приложение. Их пришлось бы писать дважды — отдельно для каждой платформы, — и два набора тестов расходятся так же, как расходятся две кодовые базы. Поскольку они находятся слишком близко к реализации, сравнение набора Swift-тестов с набором Kotlin-тестов почти ничего не говорит о том, покрывают ли оба приложения одинаковые пользовательские сценарии.
В итоге мы остановились на промежуточном варианте, который ближе к тестированию «серого ящика». Тест не видит пиксели и не видит внутреннюю реализацию. Он видит ровно то же, что видит интерфейс: состояние ViewModel и действия, которые ViewModel принимает. Всё, что находится ниже, настоящее: реальные редюсеры, реальные сервисы, настоящий GraphQL-клиент, настоящая навигация. Подменяются только данные на границах, где приложение иначе обращалось бы к сети или устройству. Во всём наборе тестов нет ни одного фреймворка для моков.
Контракт, построенный с нуля
Убираем интерфейс из уравнения
Для обсуждения архитектуры на высоком уровне возьмём небольшой пример: простое приложение для управления запасами, где пользователь выбирает товар из списка, открывает экран с подробностями и изменяет количество товара на складе. Этого достаточно, чтобы показать общую структуру; многие детали намеренно опущены. Код приведён на Swift, но на стороне Kotlin структура такая же и читается почти идентично.
Приложение на каждой платформе разделено на два модуля плюс отдельный модуль для интерфейса командной строки.
- Core содержит всё, что не относится к интерфейсу: ViewModel, сервисы, состояние навигации, GraphQL, логику хранения данных и глубокие ссылки. На iOS это обычный Swift Package, на Android — обычный JVM-модуль. Никакого UIKit, никакого
android.*. - Mobile содержит UI и платформенные интеграции: представления SwiftUI/Compose, Keychain, push-уведомления, камеру, системное меню «Поделиться». Mobile зависит от Core. Core ничего не знает о Mobile.
- CLI зависит от Core так же, как Mobile, но запускается на macOS или JVM, а не на телефоне. Именно это позволяет ему управлять приложением без экрана.

Core никогда не импортирует Mobile. Core объявляет протоколы для всех возможностей платформы, которые ему нужны, и получает реализации через конструктор. Это обычная инверсия зависимостей без фреймворка внедрения зависимостей. Мобильное приложение и интерфейс командной строки — просто среды, которые создают Core. CLI вместо настоящих платформенных сервисов использует подготовленные данные.
// ── The app: InventoryManagementMobile/InventoryManagementApp.swift ──────────
let core = InventoryManagementCore(
storage: KeychainStorage()
)
// ── The CLI: InventoryManagementCLI/main.swift — same core, no platform/UI ─
let core = InventoryManagementCore(
storage: InMemoryStorage()
)
Такое разделение даёт несколько преимуществ:
- Навигация находится в Core. Открытие экрана — это изменение состояния навигатора, поэтому CLI может пройти по всему приложению, не создавая ни одного представления. То же относится и к глубоким ссылкам.
- Core работает везде, где работает язык. Swift-ядро собирается на macOS и, в принципе, Linux; Kotlin-ядро — это чистый JVM-модуль. Для тестов поведения Android нужен JVM, а не эмулятор.
- Тесты быстрые, потому что ничего не отрисовывается. Нет прохода компоновки, анимаций или ожидания появления представления. Действие — это вызов функции с последующим чтением JSON. Но ещё важнее выполнение без интерфейса: более 1700 тестов поведения на обеих платформах завершаются примерно за четыре минуты в CI.
- Подготовленные данные передаются через конструктор. Любое состояние, которое обычно приходит извне процесса — ответ GraphQL, запись Keychain, доступность биометрии, флаг функции — передаётся в конструктор. Чтобы смоделировать пограничный случай, достаточно передать другой набор данных, а не менять мок прямо во время теста.
В самом Reflex нет сторонних зависимостей. Базовый класс ViewModel, один небольшой протокол для интроспекции и дисциплина, не позволяющая Core импортировать платформенный код. Для людей соблюдать эту дисциплину сложно. Для агентов это повторяемый шаблон, а повторять шаблоны они умеют хорошо.
Структура приложения
Внутри Core приложение представляет собой дерево обычных объектов. В корне находится объект приложения, который владеет сетевым клиентом, текущей сессией и постоянным хранилищем. Ниже расположены навигаторы — Tab, Push и Modal, — каждый из которых хранит список маршрутов, отображаемых в данный момент. На листьях дерева находится по одной ViewModel на экран. Ничто в этом дереве ничего не знает об UI.
Представления живут вне Core, в модуле Mobile. Представление привязывается к одной ViewModel, читает её состояние и вызывает send(action:). Это вся связь между представлением и остальной частью приложения.
Introspection Kit — отдельный модуль, определяющий контракт между ViewModel и интерфейсом командной строки. Каждая ViewModel получает небольшое расширение, которое описывает её как узел: имя, состояние в JSON и принимаемые действия. CLI и набор инструментов тестирования взаимодействуют только с этим слоем. Само приложение никогда его не вызывает.

Оставшаяся часть раздела разбирает каждый из этих элементов: ViewModel, узел интроспекции и то, как узлы образуют дерево, по которому может перемещаться CLI.
ViewModel: действие на входе, состояние на выходе
Каждый экран поддерживается ViewModel, структура которой вдохновлена архитектурой Elm. Она состоит из следующих частей:
- State — всё, что отображает экран и чем владеет ViewModel. Представление читает только его и ничего больше.
- Action — публичная поверхность: всё, что представление или CLI могут попросить ViewModel сделать. Описывается как
enumи передаётся черезsend(action:). - Message — внутренняя поверхность.
Actionоборачивается в.viewAction, а Message в основном используется для результатов, возвращаемых сервисами. Их видит только ViewModel. update(state, message)— reducer. Единственное место, где вообще изменяется состояние. Все пути сходятся здесь.- Services — сеть, хранилище и всё, что имеет побочные эффекты. Протоколы передаются через конструктор, поэтому CLI легко заменяет их подготовленными данными.
Весь контракт представления выглядит так: прочитать состояние, вызвать send(action:). Представление никогда не вызывает методы ViewModel напрямую и никогда не изменяет состояние.
Далее приведена ViewModel экрана подробностей. Это псевдо-Swift: без аннотаций конкурентности, без управления доступом и без всего, что не требуется для демонстрации структуры.
// InventoryManagementCore — pseudo-Swift, boilerplate omitted
final class InventoryDetailViewModel: ViewModel {
class State {
let productId: String
var stockCount = 0
var isSaved = false
}
enum Action { // what a View (or the CLI) can do
case increment, decrement, save
case setQuantity(Int) // actions can carry values
}
enum Message { // everything the reducer handles
case viewAction(Action)
case saved // results from services come back as messages
}
let service: InventoryService // a protocol, injected through the constructor
override func update(state: State, message: Message) {
switch message {
case .viewAction(.increment):
state.stockCount += 1
case .viewAction(.decrement) where state.stockCount > 0:
state.stockCount -= 1
case .viewAction(.setQuantity(let quantity)):
state.stockCount = quantity
case .viewAction(.save):
service.save(state.stockCount) { self.send(message: .saved) }
case .saved:
state.isSaved = true
default:
break // e.g. decrementing at zero stock is a no-op
}
}
func send(action: Action) { send(message: .viewAction(action)) }
}
Action — публичная поверхность, Message — внутренняя, а update — единственное место записи. Поскольку структура жёсткая, агент может правильно повторить её несколько сотен раз.
Introspection Kit: ViewModel снаружи
CLI нужен способ прочитать ViewModel и отправить ей действия. Этот слой мы называем Introspection Kit, а контракт, которому должна соответствовать ViewModel, состоит всего из одного протокола.
public protocol IntrospectionNode {
var name: String { get }
var state: [String: JSONValue] { get }
var actions: [IntrospectionAction] { get }
var child: (any IntrospectionNode)? { get }
}
ViewModel открывает себя наружу, возвращая узел из своего расширения +Introspection, которое хранит ссылку обратно на ViewModel. На Android идея та же, но с небольшим отличием: расширения Kotlin не могут заставить тип реализовать интерфейс, поэтому узел представляет собой отдельный класс InventoryDetailViewModelIntrospectionNode, расположенный рядом с ViewModel. Само приложение никогда ничего из этого не вызывает. Удалите этот файл — приложение по-прежнему соберётся и запустится; вы просто потеряете возможность управлять им из терминала.
// InventoryManagementCore/InventoryDetailViewModel+Introspection.swift
extension InventoryDetailViewModel: IntrospectionProviding {
var introspectionNode: IntrospectionNode { Introspection(viewModel: self) }
private struct Introspection: IntrospectionNode {
unowned let viewModel: InventoryDetailViewModel
let name = "InventoryDetail"
var state: [String: JSONValue] {
[
"productId": .string(viewModel.state.productId),
"stockCount": .number(viewModel.state.stockCount),
"isSaved": .bool(viewModel.state.isSaved),
]
}
var actions: [IntrospectionAction] {
[
IntrospectionAction(title: "Increment") {
viewModel.send(action: .increment)
},
IntrospectionAction(title: "Decrement") {
viewModel.send(action: .decrement)
},
IntrospectionAction(title: "Save") {
viewModel.send(action: .save)
},
IntrospectionAction(title: "SetQuantity", input: SetQuantityInput.self) {
viewModel.send(action: .setQuantity($0.quantity))
},
]
}
}
}
@IntrospectionInput // generates the JSON schema the CLI shows for this action. This can also be done manually.
struct SetQuantityInput: Codable {
let quantity: Int
}
У узла четыре части:
name— стабильный идентификатор экрана. Тесты используют его, чтобы понимать, где находятся.state— состояние ViewModel, закодированное в JSON. Здесь находится каждое поле, которое может увидеть пользователь.actions— по одной записи для каждого случаяAction: название и замыкание, вызывающееsend(action:). Действие может принимать параметр.SetQuantityобъявляет тип входных данных, а CLI отправляет JSON-объект, который декодируется в этот тип до выполнения замыкания. Макрос@IntrospectionInputгенерирует JSON-схему входных данных из их свойств, поэтому CLI может показать, что ожидает действие, и отклонить неправильно сформированный запрос ещё до того, как он попадёт в ViewModel, при этом никому не приходится вручную поддерживать эту схему.child— необязательная ссылка на узел ниже текущего. Переход поchildот корня позволяет CLI найти узел, находящийся в фокусе, а также распространять действия от родительских узлов к дочерним.
Писать это «вручную» — то есть с помощью агентов — вполне нормально: агенты хорошо воспроизводят такую структуру. Но поскольку форма узла настолько строгая, её можно и генерировать: макроса или аннотации на ViewModel достаточно, чтобы создать такой узел во время сборки.
@Introspectable // generates the node above from State and Action at build time
final class InventoryDetailViewModel: ViewModel {
class State { ... }
enum Action { case increment, decrement, save, setQuantity(Int) }
...
}
Три правила:
- Если пользователь может это увидеть, оно находится в
state. Если пользователь может это сделать, оно находится вactions. Узел — это обычное JSON-представление ViewModel. - Узел никогда не повторяет UI-логику. Он всегда перечисляет все действия без условий. Сработает ли
Decrement, когда запас равен нулю, решает ViewModel внутриupdate— ровно там же, где это решается при нажатии кнопки. Если вы ловите себя на том, что пишетеifвнутри узла, значит правило должно находиться в ViewModel, а результат — в состоянии. - Действия проходят только через
send(action:). Узел никогда не вызывает метод ViewModel напрямую и не изменяет состояние. CLI не может сделать ничего такого, чего не могло бы сделать представление.
Этот контракт предназначен не только для тестов. Мы хотели, чтобы приложение изначально было ориентировано на агентов, и та же поверхность состояния и действий, которую использует CLI, применяется Sidekick — ИИ-помощником Shopify — для выполнения действий внутри приложения. Отдельного интеграционного слоя для него нет. Всё, что агент может сделать из терминала, агент внутри приложения тоже может сделать.
Дерево
Узлы интроспекции отражают дерево объектов, описанное в начале этого раздела: по одному узлу на объект, связанные через child. Узел корня приложения содержит навигатор как дочерний элемент, а узел навигатора — ViewModel верхнего маршрута. CLI всегда фокусируется на самом глубоком узле этой цепочки — так же, как пользователь взаимодействует только с тем, что находится сверху.
Контейнеры могут намеренно передавать некоторые действия дочернему узлу в фокусе. Навигатор-стек передаёт goBack, навигатор вкладок — selectTab. Обычная ViewModel ничего не передаёт. Если попросить CLI выполнить действие, которого нет у узла в фокусе и которое не передал ни один предок, CLI откажется и перечислит доступные действия.
Транспорт
В режиме без интерфейса основной процесс читает JSON, разделённый переводами строк, из stdin и пишет его в stdout. В настоящей сборке небольшой сокет-сервер выполняет ту же работу через порт, причём сокет взаимодействует с теми же ViewModel, к которым привязан экран. Именно поэтому симулятор обновляется прямо во время ввода команд: никакого взаимодействия с отрисованным UI, представления просто наблюдают за состоянием. Сервер полностью исключается из релизных сборок, поэтому в поставляемом пользователям приложении его нет.
Наша «тестовая чаша»
Распределение тестов у нас в итоге стало больше похоже не на пирамиду, а на чашу с очень широкой средней частью:
- Модульные тесты для примитивов и общих компонентов: форматтеры, парсеры, базовая ViewModel, вспомогательные средства для пагинации. Их немного.
- Тесты поведения через CLI для всего остального: бизнес-логика, правила проверки, навигация, обработка ошибок и полные пользовательские сценарии. Именно они составляют основную часть покрытия и запускаются на обеих платформах из одного файла. Эти же тесты лежат в основе проверки поведения Helix: контрольная точка не может двигаться дальше, пока её CLI-тесты не пройдут на обеих платформах.
- Тонкий верхний слой: несколько настоящих интеграционных сценариев и набор тестов производительности, которые управляют реальными сборками через тот же протокол.

Вот несколько особенностей среднего слоя.
Никакой сети — принудительно
Сессия тестирования поведения устанавливает защиту, которая блокирует каждый HTTP-запрос в процессе-хозяине и завершает тест с ошибкой, если была хотя бы одна попытка обращения к сети, даже если приложение само поймало ошибку и восстановилось. Каждый сетевой запрос и каждая внешняя зависимость должны иметь подготовленные данные.
Плюс в том, что набор тестов не использует ввод-вывод и не страдает от нестабильности. Цена — большие наборы подготовленных данных. Один сценарий может содержать десяток ответов GraphQL, что утомительно поддерживать человеку, но не представляет проблемы для агента.
Ошибки легко читать
Когда тест «серого ящика» падает, сообщение ясно указывает, какое именно утверждение не выполнилось. Агент может действовать на основании этого сообщения, не открывая отладчик.
Тесты читаются как пользовательские сценарии
Мы написали небольшой набор инструментов тестирования для Introspection Kit поверх тестового фреймворка Bun. Он использует тот же JSON-протокол, что и CLI, не имеет зависимостей времени выполнения и добавляет к bun:test две вещи:
precondition — ждёт, пока узел в фокусе не станет тем, который ожидается;
do — выполняет действие и ждёт выполнения ожидания по обновлённому состоянию.
Далее показано, как выглядит тест экрана подробностей товара. Он выполняется без изменений на iOS и Android; тестовая оболочка сама выбирает платформу.
// Every journey starts on the list and opens one product.
async function openInventoryDetail(session, id = "1") {
// wait until the focused node is "InventoryList" before doing anything
await session.precondition("InventoryList");
// run an action, then poll the focused node until the expectation passes
await session.do({
action: "SelectProduct",
payload: {id},
expectation: ({name, state}) => {
expect(name).toBe("InventoryDetail"); // we navigated...
expect(state.productId).toBe(id); // ...to the right product
},
});
}
test("given save succeeds, the user can adjust the quantity and save it", async () => {
// boots a headless core with canned responses; closes it when the test ends
await using session = createBehaviorSession({
fixtures: [fixture("Inventory.stockCount2")],
});
await openInventoryDetail(session);
// decrement down to zero
await session.do({
action: "Decrement",
expectation: ({state}) => expect(state.stockCount).toBe(1),
});
await session.do({
action: "Decrement",
expectation: ({state}) => expect(state.stockCount).toBe(0),
});
// Decrement is always listed; the ViewModel decides it does nothing at zero
await session.do({
action: "Decrement",
expectation: ({state}) => expect(state.stockCount).toBe(0),
});
// actions with an input take a JSON payload
await session.do({
action: "SetQuantity",
payload: {quantity: 40},
expectation: ({state}) => expect(state.stockCount).toBe(40),
});
await session.do({
action: "Save",
expectation: ({state}) => expect(state.isSaved).toBe(true),
});
});
test("given save fails, the quantity stays unsaved", async () => {
await using session = createBehaviorSession({
fixtures: [fixture("Inventory.saveFails")],
});
await openInventoryDetail(session);
await session.do({
action: "Save",
expectation: ({state}) => expect(state.isSaved).toBe(false),
});
});
Оба вспомогательных метода опрашивают состояние в течение пары секунд, а затем завершаются ошибкой с последним увиденным состоянием, поэтому нет ни sleep, ни вспомогательных функций вроде «ждать появления элемента». Тест представляет собой расшифровку того, что сделал пользователь и что он увидел.
Можно делать такие тесты сколь угодно мелкими, но мы намеренно этого не делаем. В модульных тестах один маленький тест на каждый случай позволяет точно понять, где произошла ошибка. Здесь само сообщение об ошибке уже содержит эту информацию: на каком шаге сценария, после какого действия, какое состояние ожидалось и какое было получено. Агенты хорошо умеют читать такую информацию и находить причину без отдельного точечного теста, поэтому мы объединяем шаги пользовательского сценария в один тест и экономим время выполнения и сопровождения. Сценарии ошибок, которым нужны отдельные подготовленные данные, например ошибка сохранения, остаются отдельными не потому, что отличаются утверждения, а потому, что у них другой набор исходных данных.

Вот насколько быстро выполняются такие тестовые сценарии. После этапа прогрева приведённый выше тест выполняется за несколько сотен миллисекунд без устройства на обеих платформах.

Тот же тест в симуляторе iOS занимает около двух секунд.
А как насчёт открытого исходного кода?
Эта статья и есть открытый исходный код.
Публиковать библиотеку бессмысленно, потому что полезная часть — не наш код. Introspection Kit связан с нашей моделью сессий, нашими навигаторами и нашими форматами подготовленных данных, и всё это плохо переносится в другое приложение. Переносится сам Reflex — архитектура, описанная выше.
Способный агент может реализовать её для вашего приложения, на вашем языке, прямо по этой статье, и результат будет лучше соответствовать вашей кодовой базе, чем любая библиотека, которую мы могли бы упаковать.
Что нужно, чтобы перенести Reflex в своё приложение:
- Отделите платформу от ядра. Один модуль без импортов UI, который создаётся со всеми платформенными возможностями, переданными снаружи. Если вы не можете собрать ядро на macOS или JVM, значит, вы ещё не дошли до нужной точки.
- Сделайте все ViewModel одинаковыми. Действие на входе, состояние на выходе, один
update, сервисы через конструктор. - Добавьте узел интроспекции как расширение. Состояние в JSON, каждое действие — замыкание над
send(action:), никаких условий. Агенты могут генерировать эти узлы из ViewModel. - Постройте дерево и фокусируйтесь на листе. Корень приложения и навигаторы тоже являются узлами. Разрешите контейнерам передавать вниз несколько действий. Всё остальное недоступно — так же, как для пользователя недоступно всё, что находится за пределами текущего экрана.
- Завершайте тест ошибкой при любом обращении к сети. Подготовленные данные для всего. Только так большой набор тестов остаётся детерминированным.
- Пишите навыки, а не документацию. Научите агентов запускать CLI, добавлять подготовленные данные и давать имена тестам. CLI полезен лишь настолько, насколько агент умеет вовремя им воспользоваться.
- Уберите сложность, предназначенную только для людей. Никакого терминального интерфейса, красивого вывода или интерактивных мастеров. Это инструмент для агентов — он должен оставаться простым и быстрым.
Reflex дал нам цикл обратной связи, соответствующий скорости агента, и единый набор тестов, который удерживает два нативных приложения в синхронном состоянии. Сегодня этот набор является общим источником истины между нашими приложениями для iOS и Android, большую его часть пишут агенты, а самый быстрый способ понять, что делает приложение, по-прежнему заключается в том, чтобы открыть терминал и набрать ls.
Часто задаваемые вопросы
Насколько полезны такие тесты, если вы не тестируете интерфейс?
Очень полезны. Большинство проблем на экране возникает в его логике, и именно её покрывают эти тесты. Проверка того, что кнопка действительно подключена к send(action:), редко является источником ошибок.
Проверка UI происходит в другой части нашего процесса: каждая контрольная точка Helix проходит визуальную проверку относительно эталонного приложения, прежде чем изменение может попасть в основную ветку. Для небольшого числа компонентов, где действительно важны пиксели, мы сохраняем тесты по снимкам экрана.
Кроме того, мы сами ежедневно пользуемся приложением, поэтому поверх этого постоянно идёт естественное тестирование. Именно сочетание тестов поведения по состоянию ViewModel с этими двумя слоями позволяет нам решать, готов ли экран к выпуску.
Почему не использовать модульные тесты плюс UI-тесты с XCUITest и Espresso?
Модульные и нативные UI-тесты уже обсуждались выше: оба типа пишутся отдельно для каждой платформы и ничего не говорят о паритете.
Но в таком сравнении упускается ещё одна вещь — исследование приложения. Ни один из этих подходов не позволяет агенту самостоятельно ходить по приложению за пределами заранее написанного теста, а оказалось, что это не менее важно, чем сами тесты.
Агент, который может исследовать приложение с помощью ls, быстрее отлаживает его и быстрее создаёт новые функции. Кроме того, та же поверхность делает приложение изначально ориентированным на агентов для Sidekick, который в противном случае пришлось бы прикручивать отдельным слоем.
А что насчёт Appium или автоматизации устройства с помощью агентов?
Мы используем такие инструменты там, где они подходят. Но поскольку кодовая база принадлежит нам, мы смогли предоставить интерфейс «серого ящика», чтобы агент читал состояние напрямую, а не пытался восстановить его по дереву доступности или снимку экрана. Это экономит дополнительные циклы, которые агент иначе тратил бы на чтение экрана.
Но ещё более важная причина — выполнение без интерфейса: тысячи тестов завершаются за несколько минут в CI. Сквозным тестам такого масштаба понадобились бы часы, чтобы прогнать все сценарии, и значительная инфраструктура, чтобы выполнять их параллельно.
